Files
CosmicClash/server/store/redis_candidates.go
T
Josh Creek 79ab0d1404 fix(multiplayer): serve queue candidates when Redis is down, not just empty
Closes part of the 'live Redis failover' gap in §8.46, found by
reproducing a genuine Redis outage (not just an empty/partial cache)
against CandidateProjection.Snapshot with a killed miniredis instance.

CandidateProjection.Snapshot funnelled two different situations into
the same code path: the index erroring outright (Redis unreachable)
and the index coming back empty (ambiguous — a genuinely empty queue,
or a lost keyspace). Both went through Repair, which itself calls
Index.Rebuild — a second Redis round-trip that fails for exactly the
same reason the first one did. The result: a real Redis outage, or
the window during a failover, made Snapshot fail outright even though
PostgreSQL — the documented authoritative source everywhere
(RedisCandidateIndex's own comment, cmd/matcher, cmd/control-plane's
--redis-addr help text all call it a rebuildable/optional
acceleration layer) — was completely healthy. Matchmaking would stop
entirely on a Redis outage despite the architecture explicitly not
requiring that.

Snapshot now falls back to serving Source (PostgreSQL) directly
whenever the index errors OR comes back empty, and only best-effort
attempts to repopulate Redis afterward — that attempt's outcome is
deliberately ignored, since a caller must never be denied service
just because the opportunistic rebuild also hit the same down Redis.
Snapshot still fails when Source itself is unavailable; the fallback
is not unconditional.

Verified: reproduced the bug first (killed-miniredis Snapshot call
failed even though Source was healthy), then fixed it. go build/vet
clean; all pre-existing store-package tests pass unmodified,
including the two live-redis:7-alpine-container tests
(TestRealRedisCandidateIndexUpsertSnapshotRemove,
TestRealRedisCandidateProjectionRepairsAfterFlush, run against a real
container and torn down after). Two new tests cover the fallback
directly (killed miniredis, Source still served, exactly one Source
call) and that the fallback is not unconditional (both Redis and
Source down still fails). Full go test ./... -race clean across
every server package.

Remaining: live matcher-worker-under-load-during-failover integration,
i.e. running the actual matcher process against a real Redis that
goes down mid-run under concurrent load, not just this unit-level
reproduction.
2026-09-04 17:37:58 +01:00

228 lines
7.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)
}
// Snapshot never fails just because Redis specifically is unreachable.
// RedisCandidateIndex is documented everywhere (this type's own comment,
// cmd/matcher, cmd/control-plane's --redis-addr help text) as an optional,
// rebuildable acceleration layer over PostgreSQL authority -- but until this
// fix, a genuine Redis outage (not merely an empty or partial cache, an
// actual connection failure) made Snapshot fail outright: the old code
// treated "the index errored" and "the index came back empty" identically,
// funnelling both into Repair, which itself calls Index.Rebuild -- a second
// Redis round-trip that fails for exactly the same reason the first one did.
// A Redis failover or restart would have taken matchmaking down completely
// even though the authoritative Source (PostgreSQL) was perfectly healthy.
// Now: an index error or an empty read both fall back to serving Source
// directly, and only attempt to repopulate Redis on a best-effort basis --
// its outcome is deliberately ignored, since a caller must never be denied
// service just because the rebuild's own Redis write also failed.
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 && len(candidates) > 0 {
return candidates, nil
}
// Either the index errored outright, or came back empty -- indistinguishable
// from a Redis restart or a lost keyspace. Consult PostgreSQL, the
// authoritative source, either way.
source, sourceErr := p.Source(ctx, now)
if sourceErr != nil {
return nil, sourceErr
}
_ = p.Index.Rebuild(ctx, source)
return source, nil
}
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
}