Files
CosmicClash/server/store/redis_candidates.go
T

217 lines
6.7 KiB
Go

package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/redis/go-redis/v9"
)
// RedisCandidateIndex is a rebuildable acceleration index. It never decides
// ownership or claims a match; callers must source candidates from the
// durable queue projection before rebuilding it.
type RedisCandidateIndex struct {
Client *redis.Client
Prefix string
TTL time.Duration
}
// DurableCandidateSource is the authoritative queue projection used to
// repair Redis. Implementations must apply queue state and expiry rules before
// returning candidates.
type DurableCandidateSource func(context.Context, time.Time) ([]domain.Candidate, error)
// CandidateProjection couples the transient index to its durable repair
// source. A cache miss, partial write, malformed payload, or Redis restart is
// repaired before candidates are returned to a matcher.
type CandidateProjection struct {
Index RedisCandidateIndex
Source DurableCandidateSource
}
func (p CandidateProjection) Repair(ctx context.Context, now time.Time) error {
if p.Source == nil || now.IsZero() {
return fmt.Errorf("invalid candidate repair source")
}
candidates, err := p.Source(ctx, now)
if err != nil {
return err
}
return p.Index.Rebuild(ctx, candidates)
}
func (p CandidateProjection) Snapshot(ctx context.Context, now time.Time) ([]domain.Candidate, error) {
if p.Source == nil {
return nil, fmt.Errorf("invalid candidate repair source")
}
candidates, err := p.Index.Snapshot(ctx, now)
if err == nil {
// An empty index is indistinguishable from a Redis restart or a lost
// keyspace. Rebuild from PostgreSQL before returning so queued players
// are not hidden until the next enqueue mutation.
if len(candidates) == 0 {
if err := p.Repair(ctx, now); err != nil {
return nil, err
}
return p.Index.Snapshot(ctx, now)
}
return candidates, nil
}
if err := p.Repair(ctx, now); err != nil {
return nil, err
}
return p.Index.Snapshot(ctx, now)
}
func (r RedisCandidateIndex) keys() (string, string) {
prefix := r.Prefix
if prefix == "" {
prefix = "cosmic-clash"
}
return prefix + ":queue:candidates:data", prefix + ":queue:candidates:order"
}
func (r RedisCandidateIndex) validate() error {
if r.Client == nil || r.TTL <= 0 {
return fmt.Errorf("invalid Redis candidate index")
}
return nil
}
func validateRedisCandidate(candidate domain.Candidate) error {
if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() {
return fmt.Errorf("invalid candidate")
}
return nil
}
// Upsert stores the candidate payload and its deterministic enqueue ordering.
// Both keys receive a TTL so a Redis restart or abandoned index cannot become
// a permanent source of stale presence.
func (r RedisCandidateIndex) Upsert(ctx context.Context, candidate domain.Candidate) error {
if err := r.validate(); err != nil {
return err
}
if err := validateRedisCandidate(candidate); err != nil {
return err
}
payload, err := json.Marshal(candidate)
if err != nil {
return err
}
dataKey, orderKey := r.keys()
pipe := r.Client.TxPipeline()
pipe.HSet(ctx, dataKey, candidate.TicketID, payload)
pipe.ZAdd(ctx, orderKey, redis.Z{Score: float64(candidate.EnqueuedAt.UnixNano()), Member: candidate.TicketID})
pipe.Expire(ctx, dataKey, r.TTL)
pipe.Expire(ctx, orderKey, r.TTL)
_, err = pipe.Exec(ctx)
return err
}
func (r RedisCandidateIndex) Remove(ctx context.Context, ticketID string) error {
if err := r.validate(); err != nil {
return err
}
if ticketID == "" {
return fmt.Errorf("ticket ID is required")
}
dataKey, orderKey := r.keys()
pipe := r.Client.TxPipeline()
pipe.HDel(ctx, dataKey, ticketID)
pipe.ZRem(ctx, orderKey, ticketID)
_, err := pipe.Exec(ctx)
return err
}
// Snapshot reads only candidates whose enqueue timestamp is not in the
// future. Missing payloads are ignored; the durable rebuild path repairs such
// partial cache state without allowing it to affect ownership.
func (r RedisCandidateIndex) Snapshot(ctx context.Context, now time.Time) ([]domain.Candidate, error) {
if err := r.validate(); err != nil {
return nil, err
}
if now.IsZero() {
return nil, fmt.Errorf("authoritative time is required")
}
dataKey, orderKey := r.keys()
tickets, err := r.Client.ZRangeByScore(ctx, orderKey, &redis.ZRangeBy{
Min: "-inf", Max: fmt.Sprint(now.UnixNano()),
}).Result()
if err != nil {
return nil, err
}
if len(tickets) == 0 {
return []domain.Candidate{}, nil
}
payloads, err := r.Client.HMGet(ctx, dataKey, tickets...).Result()
if err != nil {
return nil, err
}
result := make([]domain.Candidate, 0, len(payloads))
for i, raw := range payloads {
var encoded []byte
switch value := raw.(type) {
case string:
encoded = []byte(value)
case []byte:
encoded = value
default:
return nil, fmt.Errorf("candidate payload missing for %s", tickets[i])
}
var candidate domain.Candidate
if err := json.Unmarshal(encoded, &candidate); err != nil {
return nil, fmt.Errorf("invalid candidate payload for %s: %w", tickets[i], err)
}
if err := validateRedisCandidate(candidate); err != nil {
return nil, fmt.Errorf("invalid candidate payload for %s: %w", tickets[i], err)
}
if candidate.EnqueuedAt.After(now) {
return nil, fmt.Errorf("candidate payload is newer than its index for %s", tickets[i])
}
result = append(result, candidate)
}
return result, nil
}
// Rebuild atomically replaces both Redis keys from the authoritative queue
// projection. It is the required path after Redis restart/failover or cache
// loss, and rejects duplicate ticket IDs before touching Redis.
func (r RedisCandidateIndex) Rebuild(ctx context.Context, candidates []domain.Candidate) error {
if err := r.validate(); err != nil {
return err
}
seen := make(map[string]struct{}, len(candidates))
values := make([]interface{}, 0, len(candidates)*2)
scores := make([]redis.Z, 0, len(candidates))
for _, candidate := range candidates {
if err := validateRedisCandidate(candidate); err != nil {
return err
}
if _, exists := seen[candidate.TicketID]; exists {
return fmt.Errorf("duplicate candidate in rebuild")
}
seen[candidate.TicketID] = struct{}{}
payload, err := json.Marshal(candidate)
if err != nil {
return err
}
values = append(values, candidate.TicketID, payload)
scores = append(scores, redis.Z{Score: float64(candidate.EnqueuedAt.UnixNano()), Member: candidate.TicketID})
}
dataKey, orderKey := r.keys()
pipe := r.Client.TxPipeline()
pipe.Del(ctx, dataKey, orderKey)
if len(values) > 0 {
pipe.HSet(ctx, dataKey, values...)
pipe.ZAdd(ctx, orderKey, scores...)
}
pipe.Expire(ctx, dataKey, r.TTL)
pipe.Expire(ctx, orderKey, r.TTL)
_, err := pipe.Exec(ctx)
return err
}