feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,93 @@
package redisstate
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"galaxy/lobby/internal/ports"
"github.com/redis/go-redis/v9"
)
// StreamLagProbe is the Redis-backed implementation of ports.StreamLagProbe.
// It uses XRANGE with an exclusive start to find the oldest entry that
// follows the saved consumer offset and parses the ms component of the
// returned entry id.
type StreamLagProbe struct {
client *redis.Client
clock func() time.Time
}
// NewStreamLagProbe constructs one Redis-backed stream-lag probe. clock is
// optional; when nil the probe falls back to time.Now.
func NewStreamLagProbe(client *redis.Client, clock func() time.Time) (*StreamLagProbe, error) {
if client == nil {
return nil, errors.New("new lobby stream lag probe: nil redis client")
}
if clock == nil {
clock = time.Now
}
return &StreamLagProbe{client: client, clock: clock}, nil
}
// OldestUnprocessedAge returns the age of the first stream entry strictly
// after savedOffset. When savedOffset is empty, the probe falls back to the
// stream head. The boolean return reports whether an entry was found.
func (probe *StreamLagProbe) OldestUnprocessedAge(ctx context.Context, stream, savedOffset string) (time.Duration, bool, error) {
if probe == nil || probe.client == nil {
return 0, false, errors.New("oldest unprocessed age: nil probe")
}
if ctx == nil {
return 0, false, errors.New("oldest unprocessed age: nil context")
}
if strings.TrimSpace(stream) == "" {
return 0, false, errors.New("oldest unprocessed age: empty stream name")
}
start := "-"
if trimmed := strings.TrimSpace(savedOffset); trimmed != "" {
start = "(" + trimmed
}
entries, err := probe.client.XRangeN(ctx, stream, start, "+", 1).Result()
if err != nil {
return 0, false, fmt.Errorf("oldest unprocessed age: %w", err)
}
if len(entries) == 0 {
return 0, false, nil
}
ms, err := parseStreamEntryMillis(entries[0].ID)
if err != nil {
return 0, false, fmt.Errorf("oldest unprocessed age: %w", err)
}
now := probe.clock()
age := now.UnixMilli() - ms
if age < 0 {
return 0, true, nil
}
return time.Duration(age) * time.Millisecond, true, nil
}
// parseStreamEntryMillis extracts the ms prefix from a Redis Stream entry
// id of the form `<ms>-<seq>`. It returns an error when the format does
// not match.
func parseStreamEntryMillis(id string) (int64, error) {
hyphen := strings.IndexByte(id, '-')
if hyphen <= 0 {
return 0, fmt.Errorf("malformed stream entry id %q", id)
}
ms, err := strconv.ParseInt(id[:hyphen], 10, 64)
if err != nil {
return 0, fmt.Errorf("malformed stream entry id %q: %w", id, err)
}
return ms, nil
}
// Compile-time interface assertion.
var _ ports.StreamLagProbe = (*StreamLagProbe)(nil)