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 }