package redisstate import ( "context" "errors" "fmt" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/ports" "github.com/redis/go-redis/v9" ) // CapabilityEvaluationGuardTTL bounds how long the guard marker survives // in Redis. The evaluator only reads the guard during `game_finished` // processing, and capability windows expire after 30 days, so a 60-day // retention is comfortably long enough to absorb any practical replay // while still letting the keyspace reclaim space eventually. const CapabilityEvaluationGuardTTL time.Duration = 60 * 24 * time.Hour // EvaluationGuardStore stores per-game «already evaluated» markers in Redis // using SETNX semantics. The first MarkEvaluated call for a gameID records // the marker; later calls observe the existing key and return already=true. type EvaluationGuardStore struct { client *redis.Client keys Keyspace ttl time.Duration } // NewEvaluationGuardStore constructs one Redis-backed EvaluationGuardStore // using the default guard TTL. func NewEvaluationGuardStore(client *redis.Client) (*EvaluationGuardStore, error) { if client == nil { return nil, errors.New("new lobby evaluation guard store: nil redis client") } return &EvaluationGuardStore{ client: client, keys: Keyspace{}, ttl: CapabilityEvaluationGuardTTL, }, nil } // IsEvaluated reports whether gameID is already marked. It performs a // single GET against the guard key and treats the missing-key case as // not-yet-evaluated. func (store *EvaluationGuardStore) IsEvaluated(ctx context.Context, gameID common.GameID) (bool, error) { if store == nil || store.client == nil { return false, errors.New("is evaluated: nil store") } if ctx == nil { return false, errors.New("is evaluated: nil context") } if err := gameID.Validate(); err != nil { return false, fmt.Errorf("is evaluated: %w", err) } _, err := store.client.Get(ctx, store.keys.CapabilityEvaluationGuard(gameID)).Result() switch { case err == nil: return true, nil case errors.Is(err, redis.Nil): return false, nil default: return false, fmt.Errorf("is evaluated: %w", err) } } // MarkEvaluated records gameID as evaluated. Calling MarkEvaluated twice // for the same gameID is safe; the second call leaves the marker // untouched and refreshes the TTL. func (store *EvaluationGuardStore) MarkEvaluated(ctx context.Context, gameID common.GameID) error { if store == nil || store.client == nil { return errors.New("mark evaluated: nil store") } if ctx == nil { return errors.New("mark evaluated: nil context") } if err := gameID.Validate(); err != nil { return fmt.Errorf("mark evaluated: %w", err) } if err := store.client.Set( ctx, store.keys.CapabilityEvaluationGuard(gameID), "1", store.ttl, ).Err(); err != nil { return fmt.Errorf("mark evaluated: %w", err) } return nil } // Compile-time interface assertion. var _ ports.EvaluationGuardStore = (*EvaluationGuardStore)(nil)