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 `-`. 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)