109 lines
3.5 KiB
Go
109 lines
3.5 KiB
Go
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)
|