94 lines
2.7 KiB
Go
94 lines
2.7 KiB
Go
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)
|