146 lines
5.0 KiB
Go
146 lines
5.0 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSharedRedisIntegration(t *testing.T) {
|
|
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
|
|
if address == "" {
|
|
t.Skip("requires isolated Redis")
|
|
}
|
|
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 8, MaxRetries: -1, ContextTimeoutEnabled: true, ReadTimeout: time.Second, WriteTimeout: time.Second})
|
|
defer client.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
prefix := fmt.Sprint("shared:", time.Now().UnixNano())
|
|
s, err := NewIdempotencyStore(client, prefix, IdempotencyConfig{MaxEntries: 256})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Del(context.Background(), s.keys...)
|
|
key, fp := strings.Repeat("a", 64), strings.Repeat("b", 64)
|
|
var claims atomic.Int32
|
|
var wg sync.WaitGroup
|
|
owners := make(chan string, 32)
|
|
for i := 0; i < 32; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
owner := fmt.Sprintf("%032x", i)
|
|
_, ok, err := s.Claim(ctx, key, fp, owner)
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
if ok {
|
|
claims.Add(1)
|
|
owners <- owner
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
if claims.Load() != 1 {
|
|
t.Fatal(claims.Load())
|
|
}
|
|
owner := <-owners
|
|
if err = s.Complete(ctx, key, "wrong", nil, time.Minute); err == nil {
|
|
t.Fatal("foreign completion accepted")
|
|
}
|
|
if err = s.Complete(ctx, key, owner, &middleware.StoredResponse{Status: 201, Headers: http.Header{}, Body: []byte{0, 1, 255}}, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, ok, err := s.Claim(ctx, key, fp, strings.Repeat("c", 32))
|
|
if err != nil || ok || record.State != "completed" || len(record.Response.Body) != 3 {
|
|
t.Fatal(record, ok, err)
|
|
}
|
|
// Two independently constructed apps use the same backend, so only one runs.
|
|
var executions atomic.Int32
|
|
makeApp := func() *omni.App {
|
|
backend, _ := NewIdempotencyStore(client, prefix, IdempotencyConfig{MaxEntries: 256})
|
|
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: backend, Scope: func(*httpx.Context) (string, error) { return "verifiedTenant", nil }})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := omni.New(omni.WithLogger(false))
|
|
a.POST("/submit", func(c *httpx.Context) error { executions.Add(1); return c.OK("saved") }, idem.Middleware())
|
|
return a
|
|
}
|
|
a, b := makeApp(), makeApp()
|
|
request := func(a *omni.App, body string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest("POST", "/submit", strings.NewReader(body))
|
|
r.Header.Set("Idempotency-Key", "stableKey")
|
|
w := httptest.NewRecorder()
|
|
a.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
first, second := request(a, `{}`), request(b, `{}`)
|
|
if first.Code != 200 || second.Code != 200 || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" || executions.Load() != 1 {
|
|
t.Fatal(first.Code, second.Code, second.Body.String(), executions.Load())
|
|
}
|
|
if request(b, `{"changed":true}`).Code != 409 {
|
|
t.Fatal("conflict accepted")
|
|
}
|
|
// Shared limits do not multiply when a second replica is added.
|
|
limitPrefix := prefix + "limit"
|
|
cfg := middleware.RateLimitConfig{Rate: .01, Burst: 2, Key: func(*httpx.Context) string { return "tenant" }}
|
|
r1, _ := NewRateLimiter(client, limitPrefix, cfg)
|
|
r2, _ := NewRateLimiter(client, limitPrefix, cfg)
|
|
base, _ := namespace(limitPrefix, "rate")
|
|
defer client.Del(context.Background(), base+":buckets", base+":seen", base+":config")
|
|
limited := func(l *middleware.RateLimiter) int {
|
|
a := omni.New(omni.WithLogger(false))
|
|
a.GET("/", func(c *httpx.Context) error { return c.OK("ok") }, l.Middleware())
|
|
w := httptest.NewRecorder()
|
|
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
|
return w.Code
|
|
}
|
|
if limited(r1) != 200 || limited(r2) != 200 || limited(r1) != 429 {
|
|
t.Fatal("limits multiplied across instances")
|
|
}
|
|
// Redis failure must fail closed before executing a new request.
|
|
client.Close()
|
|
if status := limited(r2); status != 503 {
|
|
t.Fatal(status)
|
|
}
|
|
}
|
|
|
|
func TestExpiredTargetBeyondCleanupBatch(t *testing.T) {
|
|
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
|
|
if address == "" {
|
|
t.Skip("requires isolated Redis")
|
|
}
|
|
client := goredis.NewClient(&goredis.Options{Addr: address})
|
|
defer client.Close()
|
|
s, _ := NewIdempotencyStore(client, fmt.Sprint("expiry:", time.Now().UnixNano()), IdempotencyConfig{MaxEntries: 256})
|
|
defer client.Del(context.Background(), s.keys...)
|
|
ctx := context.Background()
|
|
owner := strings.Repeat("1", 32)
|
|
fp := strings.Repeat("2", 64)
|
|
for i := 0; i < 140; i++ {
|
|
key := fmt.Sprintf("%064x", i)
|
|
if _, ok, err := s.Claim(ctx, key, fp, owner); err != nil || !ok {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Complete(ctx, key, owner, nil, time.Minute); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client.ZAdd(ctx, s.keys[1], goredis.Z{Score: 0, Member: key})
|
|
}
|
|
if _, ok, err := s.Claim(ctx, fmt.Sprintf("%064x", 139), fp, owner); err != nil || !ok {
|
|
t.Fatal("expired target retained beyond TTL", ok, err)
|
|
}
|
|
}
|