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)} } // RebuildFromQueue is the safe restart/failover path for the cache. Queue // expiry and state filtering happen at the authoritative source before the // cache is atomically replaced; callers never have to reconstruct those // rules from a stale Redis index. func RebuildFromQueue(cache *CandidateCache, queue *domain.Queue, now time.Time) error { if cache == nil || queue == nil || now.IsZero() { return fmt.Errorf("invalid candidate rebuild arguments") } return cache.Rebuild(queue.Candidates(now)) } 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 }