package redisstate import ( "context" "encoding/json" "errors" "fmt" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/ports" "github.com/redis/go-redis/v9" ) // GapActivationRecordTTL is the Redis retention applied to gap activation // timestamps. uses zero (no expiry); the worker that consumes // these records will revisit retention when the surface // stabilizes. const GapActivationRecordTTL time.Duration = 0 // gapActivationRecord stores the strict Redis JSON shape used for one // gap-window activation timestamp. type gapActivationRecord struct { ActivatedAtMS int64 `json:"activated_at_ms"` } // GapActivationStore provides Redis-backed durable storage for gap-window // activation timestamps used by enrollment automation. type GapActivationStore struct { client *redis.Client keys Keyspace } // NewGapActivationStore constructs one Redis-backed gap activation store. // It returns an error when client is nil. func NewGapActivationStore(client *redis.Client) (*GapActivationStore, error) { if client == nil { return nil, errors.New("new gap activation store: nil redis client") } return &GapActivationStore{client: client, keys: Keyspace{}}, nil } // MarkActivated writes at as the gap activation timestamp for gameID iff // no prior activation exists. A second call is a silent no-op. func (store *GapActivationStore) MarkActivated(ctx context.Context, gameID common.GameID, at time.Time) error { if store == nil || store.client == nil { return errors.New("mark gap activation: nil store") } if ctx == nil { return errors.New("mark gap activation: nil context") } if err := gameID.Validate(); err != nil { return fmt.Errorf("mark gap activation: %w", err) } if at.IsZero() { return errors.New("mark gap activation: at must not be zero") } payload, err := json.Marshal(gapActivationRecord{ActivatedAtMS: at.UTC().UnixMilli()}) if err != nil { return fmt.Errorf("mark gap activation: %w", err) } args := redis.SetArgs{Mode: "NX"} if GapActivationRecordTTL > 0 { args.TTL = GapActivationRecordTTL } if _, err := store.client.SetArgs(ctx, store.keys.GapActivatedAt(gameID), payload, args).Result(); err != nil && !errors.Is(err, redis.Nil) { return fmt.Errorf("mark gap activation: %w", err) } return nil } // Get returns the gap-window activation time previously recorded for // gameID. The second return value is false when no activation has been // recorded. func (store *GapActivationStore) Get(ctx context.Context, gameID common.GameID) (time.Time, bool, error) { if store == nil || store.client == nil { return time.Time{}, false, errors.New("get gap activation: nil store") } if ctx == nil { return time.Time{}, false, errors.New("get gap activation: nil context") } if err := gameID.Validate(); err != nil { return time.Time{}, false, fmt.Errorf("get gap activation: %w", err) } raw, err := store.client.Get(ctx, store.keys.GapActivatedAt(gameID)).Bytes() if err != nil { if errors.Is(err, redis.Nil) { return time.Time{}, false, nil } return time.Time{}, false, fmt.Errorf("get gap activation: %w", err) } var record gapActivationRecord if err := json.Unmarshal(raw, &record); err != nil { return time.Time{}, false, fmt.Errorf("get gap activation: %w", err) } if record.ActivatedAtMS <= 0 { return time.Time{}, false, fmt.Errorf("get gap activation: activated_at_ms %d must be positive", record.ActivatedAtMS) } return time.UnixMilli(record.ActivatedAtMS).UTC(), true, nil } // Compile-time interface assertion. var _ ports.GapActivationStore = (*GapActivationStore)(nil)