Files
multica/server/internal/service/empty_claim_cache_test.go
Bohan Jiang 81bde585ba MUL-3467: batch load squad roster skills (#4386)
* MUL-3467: batch load squad roster skills

Co-authored-by: multica-agent <github@multica.ai>

* test: isolate redis-backed test databases

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 13:14:22 +08:00

166 lines
4.4 KiB
Go

package service
import (
"context"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
const redisTestDB = 12
// newRedisTestClient connects to REDIS_TEST_URL, uses this package's logical
// test DB, flushes, and skips when unset so `go test ./...` works on a stock
// laptop without a Redis instance running.
func newRedisTestClient(t *testing.T) *redis.Client {
t.Helper()
url := os.Getenv("REDIS_TEST_URL")
if url == "" {
t.Skip("REDIS_TEST_URL not set")
}
opts, err := redis.ParseURL(url)
if err != nil {
t.Fatalf("parse REDIS_TEST_URL: %v", err)
}
opts.DB = redisTestDB
rdb := redis.NewClient(opts)
ctx := context.Background()
if err := rdb.Ping(ctx).Err(); err != nil {
t.Skipf("REDIS_TEST_URL unreachable: %v", err)
}
if err := rdb.FlushDB(ctx).Err(); err != nil {
t.Fatalf("flushdb: %v", err)
}
t.Cleanup(func() {
rdb.FlushDB(context.Background())
rdb.Close()
})
return rdb
}
func TestEmptyClaimCache_NilSafe(t *testing.T) {
var c *EmptyClaimCache // nil
ctx := context.Background()
if c.IsEmpty(ctx, "any-runtime") {
t.Fatal("nil cache must report not-empty (cache miss)")
}
if v := c.CurrentVersion(ctx, "any-runtime"); v != 0 {
t.Fatalf("nil cache CurrentVersion must be 0, got %d", v)
}
c.MarkEmpty(ctx, "any-runtime", 0)
c.Bump(ctx, "any-runtime")
}
func TestNewEmptyClaimCache_NilRedisReturnsNil(t *testing.T) {
if c := NewEmptyClaimCache(nil); c != nil {
t.Fatalf("NewEmptyClaimCache(nil) must return nil, got %#v", c)
}
}
func TestEmptyClaimCache_EmptyRuntimeIDIsNoOp(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewEmptyClaimCache(rdb)
ctx := context.Background()
c.MarkEmpty(ctx, "", 0)
if c.IsEmpty(ctx, "") {
t.Fatal("empty runtime ID must not hit cache")
}
c.Bump(ctx, "")
}
func TestEmptyClaimCache_MarkAndIsEmptyVersionMatched(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewEmptyClaimCache(rdb)
ctx := context.Background()
if c.IsEmpty(ctx, "rt-1") {
t.Fatal("expected miss before mark")
}
v0 := c.CurrentVersion(ctx, "rt-1")
c.MarkEmpty(ctx, "rt-1", v0)
if !c.IsEmpty(ctx, "rt-1") {
t.Fatal("expected hit when MarkEmpty version matches current")
}
}
// TestEmptyClaimCache_BumpInvalidatesPriorMark is the core race-fix
// pin: an empty verdict written under v0 must be rejected once Bump
// advances the version to v1, even though the empty key itself still
// has TTL remaining.
func TestEmptyClaimCache_BumpInvalidatesPriorMark(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewEmptyClaimCache(rdb)
ctx := context.Background()
v0 := c.CurrentVersion(ctx, "rt-bump")
c.MarkEmpty(ctx, "rt-bump", v0)
if !c.IsEmpty(ctx, "rt-bump") {
t.Fatal("precondition: empty verdict tagged with current version should hit")
}
c.Bump(ctx, "rt-bump")
if c.IsEmpty(ctx, "rt-bump") {
t.Fatal("Bump must invalidate the prior empty verdict")
}
}
// TestEmptyClaimCache_StaleMarkRejected pins the GPT-Boy race: a slow
// claim reads version v0, the SELECT sees no rows, an enqueue Bumps
// to v1, then the slow claim writes MarkEmpty(v0). The next reader
// must NOT trust this verdict.
func TestEmptyClaimCache_StaleMarkRejected(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewEmptyClaimCache(rdb)
ctx := context.Background()
// Slow claim samples version BEFORE select.
v0 := c.CurrentVersion(ctx, "rt-race")
// Concurrent enqueue happens.
c.Bump(ctx, "rt-race")
// Slow claim writes its empty verdict tagged with the stale v0.
c.MarkEmpty(ctx, "rt-race", v0)
if c.IsEmpty(ctx, "rt-race") {
t.Fatal("MarkEmpty written under a pre-Bump version must be rejected on read")
}
}
func TestEmptyClaimCache_TTL(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewEmptyClaimCache(rdb)
ctx := context.Background()
c.MarkEmpty(ctx, "rt-ttl", 0)
ttl, err := rdb.TTL(ctx, emptyClaimKey("rt-ttl")).Result()
if err != nil {
t.Fatalf("TTL: %v", err)
}
if ttl <= 0 || ttl > EmptyClaimCacheTTL+time.Second {
t.Fatalf("unexpected empty-key TTL %v (want ~%v)", ttl, EmptyClaimCacheTTL)
}
}
func TestEmptyClaimCache_RuntimeIsolation(t *testing.T) {
rdb := newRedisTestClient(t)
c := NewEmptyClaimCache(rdb)
ctx := context.Background()
vA := c.CurrentVersion(ctx, "rt-A")
c.MarkEmpty(ctx, "rt-A", vA)
if c.IsEmpty(ctx, "rt-B") {
t.Fatal("marking rt-A must not affect rt-B")
}
c.Bump(ctx, "rt-A")
vB := c.CurrentVersion(ctx, "rt-B")
c.MarkEmpty(ctx, "rt-B", vB)
if c.IsEmpty(ctx, "rt-A") {
t.Fatal("marking rt-B must not affect rt-A")
}
}