mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 21:12:03 +00:00
feat: add rebuildable candidate cache
This commit is contained in:
+1
-1
@@ -1191,7 +1191,7 @@ the local/CI/community transport, not a silent production fallback.
|
|||||||
|
|
||||||
| # | Task | Acceptance |
|
| # | Task | Acceptance |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection | `server/domain/queue.go` has adversarial ownership/expiry/idempotency tests; PostgreSQL transaction adapter, Redis candidate index and cache-loss repair remain |
|
| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary | `server/domain/queue.go` and `server/store/candidates.go` cover ownership/expiry/idempotency, deterministic projection, cache loss and atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover 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 | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; Steam coordinator and regional probe adapters 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 | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; 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 | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures 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 | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures 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 | `server/domain/proposal.go` and adversarial fixtures cover partial/unanimous response, expiry, replay/conflict and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation 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 | `server/domain/proposal.go` and adversarial fixtures cover partial/unanimous response, expiry, replay/conflict and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain |
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CandidateCache is intentionally rebuildable. A real Redis implementation
|
||||||
|
// can satisfy this interface, but no cache operation is an ownership fence.
|
||||||
|
type CandidateCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
candidates map[string]domain.Candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCandidateCache() *CandidateCache {
|
||||||
|
return &CandidateCache{candidates: make(map[string]domain.Candidate)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CandidateCache) Upsert(candidate domain.Candidate) error {
|
||||||
|
if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() {
|
||||||
|
return fmt.Errorf("invalid candidate")
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.candidates[candidate.TicketID] = candidate
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CandidateCache) Remove(ticketID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.candidates, ticketID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CandidateCache) Snapshot(now time.Time) []domain.Candidate {
|
||||||
|
c.mu.RLock()
|
||||||
|
result := make([]domain.Candidate, 0, len(c.candidates))
|
||||||
|
for _, candidate := range c.candidates {
|
||||||
|
if !candidate.EnqueuedAt.After(now) {
|
||||||
|
result = append(result, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if !result[i].EnqueuedAt.Equal(result[j].EnqueuedAt) {
|
||||||
|
return result[i].EnqueuedAt.Before(result[j].EnqueuedAt)
|
||||||
|
}
|
||||||
|
return result[i].TicketID < result[j].TicketID
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild replaces the cache atomically with the authoritative queue view.
|
||||||
|
// Callers should invoke this after Redis restart, failover, or a cache miss;
|
||||||
|
// the supplied candidates must already have passed durable queue checks.
|
||||||
|
func (c *CandidateCache) Rebuild(candidates []domain.Candidate) error {
|
||||||
|
rebuilt := make(map[string]domain.Candidate, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() {
|
||||||
|
return fmt.Errorf("invalid candidate in rebuild")
|
||||||
|
}
|
||||||
|
if _, exists := rebuilt[candidate.TicketID]; exists {
|
||||||
|
return fmt.Errorf("duplicate candidate in rebuild")
|
||||||
|
}
|
||||||
|
rebuilt[candidate.TicketID] = candidate
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.candidates = rebuilt
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCandidateCacheRebuildRepairsLossAndKeepsDeterministicOrder(t *testing.T) {
|
||||||
|
now := time.Unix(1000, 0)
|
||||||
|
cache := NewCandidateCache()
|
||||||
|
candidates := []domain.Candidate{{TicketID: "ticket-b", PlayerID: "player-b", EnqueuedAt: now}, {TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: now}}
|
||||||
|
if err := cache.Rebuild(candidates); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache.Remove("ticket-a")
|
||||||
|
if got := cache.Snapshot(now); len(got) != 1 || got[0].TicketID != "ticket-b" {
|
||||||
|
t.Fatalf("stale cache snapshot = %+v", got)
|
||||||
|
}
|
||||||
|
if err := cache.Rebuild(candidates); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := cache.Snapshot(now)
|
||||||
|
if len(got) != 2 || got[0].TicketID != "ticket-a" || got[1].TicketID != "ticket-b" {
|
||||||
|
t.Fatalf("repaired order = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateCacheRejectsInvalidOrDuplicateDurableProjection(t *testing.T) {
|
||||||
|
cache := NewCandidateCache()
|
||||||
|
if err := cache.Upsert(domain.Candidate{TicketID: "", PlayerID: "p", EnqueuedAt: time.Unix(1000, 0)}); err == nil {
|
||||||
|
t.Fatal("invalid candidate accepted")
|
||||||
|
}
|
||||||
|
candidate := domain.Candidate{TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: time.Unix(1000, 0)}
|
||||||
|
if err := cache.Rebuild([]domain.Candidate{candidate, candidate}); err == nil {
|
||||||
|
t.Fatal("duplicate candidate accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user