// Package gamelease implements the Redis-backed adapter for // `ports.GameLeaseStore`. // // The lease guards every lifecycle operation Runtime Manager runs // against one game (start, stop, restart, patch, cleanup, plus the // reconciler's drift mutations). Acquisition uses `SET NX PX ` // with a random caller token; release runs a Lua compare-and-delete // so a holder that lost the lease through TTL expiry cannot wipe // another caller's claim. package gamelease import ( "context" "errors" "fmt" "strings" "time" "galaxy/rtmanager/internal/adapters/redisstate" "galaxy/rtmanager/internal/ports" "github.com/redis/go-redis/v9" ) // releaseScript removes the per-game lease only when the supplied token // still owns it. Compare-and-delete prevents a TTL-expired holder from // clearing another caller's claim. var releaseScript = redis.NewScript(` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0 `) // Config configures one Redis-backed game lease store instance. The // store does not own the redis client lifecycle; the caller (typically // the service runtime) opens and closes it. type Config struct { // Client stores the Redis client the store uses for every command. Client *redis.Client } // Store persists the per-game lifecycle lease in Redis. type Store struct { client *redis.Client keys redisstate.Keyspace } // New constructs one Redis-backed game lease store from cfg. func New(cfg Config) (*Store, error) { if cfg.Client == nil { return nil, errors.New("new rtmanager game lease store: nil redis client") } return &Store{ client: cfg.Client, keys: redisstate.Keyspace{}, }, nil } // TryAcquire attempts to acquire the per-game lease for gameID owned by // token for ttl. The acquired return is true on a successful claim and // false when another caller still owns the lease. A non-nil error // reports a transport failure and must not be confused with a missed // lease. func (store *Store) TryAcquire(ctx context.Context, gameID, token string, ttl time.Duration) (bool, error) { if store == nil || store.client == nil { return false, errors.New("try acquire game lease: nil store") } if ctx == nil { return false, errors.New("try acquire game lease: nil context") } if strings.TrimSpace(gameID) == "" { return false, errors.New("try acquire game lease: game id must not be empty") } if strings.TrimSpace(token) == "" { return false, errors.New("try acquire game lease: token must not be empty") } if ttl <= 0 { return false, errors.New("try acquire game lease: ttl must be positive") } acquired, err := store.client.SetNX(ctx, store.keys.GameLease(gameID), token, ttl).Result() if err != nil { return false, fmt.Errorf("try acquire game lease: %w", err) } return acquired, nil } // Release removes the per-game lease for gameID only when token still // matches the stored owner value. A token mismatch is a silent no-op. func (store *Store) Release(ctx context.Context, gameID, token string) error { if store == nil || store.client == nil { return errors.New("release game lease: nil store") } if ctx == nil { return errors.New("release game lease: nil context") } if strings.TrimSpace(gameID) == "" { return errors.New("release game lease: game id must not be empty") } if strings.TrimSpace(token) == "" { return errors.New("release game lease: token must not be empty") } if err := releaseScript.Run( ctx, store.client, []string{store.keys.GameLease(gameID)}, token, ).Err(); err != nil { return fmt.Errorf("release game lease: %w", err) } return nil } // Compile-time assertion: Store implements ports.GameLeaseStore. var _ ports.GameLeaseStore = (*Store)(nil)