From c8a542a3af8920d6d9cdc69555419e54d1436081 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:02:55 +0100 Subject: [PATCH] feat: repair Redis candidates from durable source --- multiplayer-next.md | 5 +- multiplayer-todo.md | 2 +- server/store/candidate_projection_test.go | 48 ++++++++++++++++++ server/store/redis_candidates.go | 62 ++++++++++++++++++++--- 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 server/store/candidate_projection_test.go diff --git a/multiplayer-next.md b/multiplayer-next.md index bf47a21c..a0181b89 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -42,8 +42,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). create/heartbeat/cancel/recovery adapters; an opt-in pgx/Docker harness now executes the migrations and real queue create/idempotency/ownership/recovery path, and a TTL-bound Redis candidate index now supports atomic rebuild, - snapshot and removal; proposal/result transactions and live Redis - restart/failover gates remain. + snapshot and removal with durable-source repair on partial/malformed cache + state; proposal/result transactions and live Redis restart/failover gates + remain. - [ ] **IN PROGRESS:** Define assignment compatibility and opt-in `ServerConfig` flags whose defaults reproduce the community-server path. Allocation manifest validation now covers client build and future expiry; signed admission remains. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index ecbfa40c..8f0633bb 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1192,7 +1192,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go`, `server/store/queue_sql.go`, `server/store/redis_candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, owner/revision-scoped SQL heartbeat/cancel/recovery, injectable PostgreSQL queue backend selected by the HTTP service, authoritative queue-to-cache rebuild, expired recovery as a terminal error, owner-scoped SQL recovery with authoritative expiry handling, server-owned candidate resolution, strict compatibility metadata, TTL-bound Redis upsert/remove/snapshot and atomic rebuild; opt-in real PostgreSQL execution now covers create/replay/active-player fencing, owner recovery, revision-fenced heartbeat/cancel and expiry, while miniredis covers Redis behavior; live Redis restart/failover and worker integration remain | +| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go`, `server/store/queue_sql.go`, `server/store/redis_candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, owner/revision-scoped SQL heartbeat/cancel/recovery, injectable PostgreSQL queue backend selected by the HTTP service, authoritative queue-to-cache rebuild, expired recovery as a terminal error, owner-scoped SQL recovery with authoritative expiry handling, server-owned candidate resolution, strict compatibility metadata, TTL-bound Redis upsert/remove/snapshot and atomic durable-source repair on partial/malformed cache state; opt-in real PostgreSQL execution now covers create/replay/active-player fencing, owner recovery, revision-fenced heartbeat/cancel and expiry, while miniredis covers Redis behavior and repair-source failure; live Redis restart/failover and worker integration remain | | 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release; authenticated HTTP now accepts only opaque location/nonce input through a server-owned probe provider | `server/domain/probes.go`, adversarial fixtures and `server/api/service.go` cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine, five-clean release, authenticated provider arguments and rejection of client RTT fields; Steam coordinator and regional probe adapters remain | | 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning; queue-backed formation now consumes the server-owned projection, fences duplicate player identities and rejects playlist/build/protocol mixing | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance, malformed candidates, duplicate identities, compatibility mismatches and queue-backed oldest-anchor formation; full population fixtures and durable matcher claim integration remain | | 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API exposes revisioned accept/decline mutations; formed matches now pass through a playlist-aware proposal boundary | `server/domain/proposal.go`, `formation.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision, casual lineup preparation and ranked metadata validation; queue precedence and allocation integration remain | diff --git a/server/store/candidate_projection_test.go b/server/store/candidate_projection_test.go new file mode 100644 index 00000000..dbf69dc4 --- /dev/null +++ b/server/store/candidate_projection_test.go @@ -0,0 +1,48 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/cosmic-clash/cosmic-clash/server/domain" + "github.com/redis/go-redis/v9" +) + +func TestCandidateProjectionRepairsPartialRedisStateFromDurableSource(t *testing.T) { + mini, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer mini.Close() + client := redis.NewClient(&redis.Options{Addr: mini.Addr()}) + defer client.Close() + now := time.Unix(1000, 0).UTC() + candidate := domain.Candidate{TicketID: "repair-ticket", PlayerID: "repair-player", EnqueuedAt: now} + index := RedisCandidateIndex{Client: client, Prefix: "repair", TTL: time.Minute} + _, orderKey := index.keys() + if err := client.ZAdd(context.Background(), orderKey, redis.Z{Score: float64(now.UnixNano()), Member: candidate.TicketID}).Err(); err != nil { + t.Fatal(err) + } + projection := CandidateProjection{Index: index, Source: func(context.Context, time.Time) ([]domain.Candidate, error) { + return []domain.Candidate{candidate}, nil + }} + got, err := projection.Snapshot(context.Background(), now) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].TicketID != candidate.TicketID { + t.Fatalf("repaired projection = %+v", got) + } +} + +func TestCandidateProjectionDoesNotReturnCacheWhenRepairSourceFails(t *testing.T) { + index := RedisCandidateIndex{TTL: time.Minute} + projection := CandidateProjection{Index: index, Source: func(context.Context, time.Time) ([]domain.Candidate, error) { + return nil, context.DeadlineExceeded + }} + if _, err := projection.Snapshot(context.Background(), time.Unix(1000, 0)); err == nil { + t.Fatal("cache projection succeeded without a usable Redis/index source") + } +} diff --git a/server/store/redis_candidates.go b/server/store/redis_candidates.go index 5848d6fd..fa4c8308 100644 --- a/server/store/redis_candidates.go +++ b/server/store/redis_candidates.go @@ -19,6 +19,44 @@ type RedisCandidateIndex struct { 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 { + 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 == "" { @@ -105,17 +143,25 @@ func (r RedisCandidateIndex) Snapshot(ctx context.Context, now time.Time) ([]dom return nil, err } result := make([]domain.Candidate, 0, len(payloads)) - for _, raw := range payloads { - text, ok := raw.(string) - if !ok { - continue + 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([]byte(text), &candidate); err != nil { - continue + 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 || candidate.EnqueuedAt.After(now) { - continue + 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) }