Files
galaxy-game/notification/internal/adapters/redisstate/stream_offset_store.go
T
2026-04-22 08:49:45 +02:00

161 lines
4.9 KiB
Go

package redisstate
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"galaxy/notification/internal/telemetry"
"github.com/redis/go-redis/v9"
)
// StreamOffsetStore provides the Redis-backed storage used for persisted
// plain-XREAD consumer progress.
type StreamOffsetStore struct {
client *redis.Client
keys Keyspace
}
// NewStreamOffsetStore constructs one Redis-backed stream-offset store.
func NewStreamOffsetStore(client *redis.Client) (*StreamOffsetStore, error) {
if client == nil {
return nil, errors.New("new notification stream offset store: nil redis client")
}
return &StreamOffsetStore{
client: client,
keys: Keyspace{},
}, nil
}
// Load returns the last processed entry id for stream when one is stored.
func (store *StreamOffsetStore) Load(ctx context.Context, stream string) (string, bool, error) {
if store == nil || store.client == nil {
return "", false, errors.New("load notification stream offset: nil store")
}
if ctx == nil {
return "", false, errors.New("load notification stream offset: nil context")
}
payload, err := store.client.Get(ctx, store.keys.StreamOffset(stream)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return "", false, nil
case err != nil:
return "", false, fmt.Errorf("load notification stream offset: %w", err)
}
offset, err := UnmarshalStreamOffset(payload)
if err != nil {
return "", false, fmt.Errorf("load notification stream offset: %w", err)
}
return offset.LastProcessedEntryID, true, nil
}
// Save stores the last processed entry id for stream.
func (store *StreamOffsetStore) Save(ctx context.Context, stream string, entryID string) error {
if store == nil || store.client == nil {
return errors.New("save notification stream offset: nil store")
}
if ctx == nil {
return errors.New("save notification stream offset: nil context")
}
offset := StreamOffset{
Stream: stream,
LastProcessedEntryID: entryID,
UpdatedAt: time.Now().UTC().Truncate(time.Millisecond),
}
payload, err := MarshalStreamOffset(offset)
if err != nil {
return fmt.Errorf("save notification stream offset: %w", err)
}
if err := store.client.Set(ctx, store.keys.StreamOffset(stream), payload, 0).Err(); err != nil {
return fmt.Errorf("save notification stream offset: %w", err)
}
return nil
}
// IntentStreamLagReader provides Redis-backed lag snapshots for one intent
// stream.
type IntentStreamLagReader struct {
store *StreamOffsetStore
stream string
}
// NewIntentStreamLagReader constructs a lag reader for stream using store.
func NewIntentStreamLagReader(store *StreamOffsetStore, stream string) (*IntentStreamLagReader, error) {
if store == nil || store.client == nil {
return nil, errors.New("new notification intent stream lag reader: nil store")
}
if strings.TrimSpace(stream) == "" {
return nil, errors.New("new notification intent stream lag reader: stream must not be empty")
}
return &IntentStreamLagReader{
store: store,
stream: stream,
}, nil
}
// ReadIntentStreamLagSnapshot returns the oldest stream entry that is newer
// than the persisted plain-XREAD consumer offset for the configured stream.
func (reader *IntentStreamLagReader) ReadIntentStreamLagSnapshot(ctx context.Context) (telemetry.IntentStreamLagSnapshot, error) {
if reader == nil || reader.store == nil {
return telemetry.IntentStreamLagSnapshot{}, errors.New("read notification intent stream lag snapshot: nil reader")
}
if ctx == nil {
return telemetry.IntentStreamLagSnapshot{}, errors.New("read notification intent stream lag snapshot: nil context")
}
lastID, found, err := reader.store.Load(ctx, reader.stream)
if err != nil {
return telemetry.IntentStreamLagSnapshot{}, fmt.Errorf("read notification intent stream lag snapshot: %w", err)
}
minID := "-"
if found {
minID = "(" + lastID
}
messages, err := reader.store.client.XRangeN(ctx, reader.stream, minID, "+", 1).Result()
if err != nil {
return telemetry.IntentStreamLagSnapshot{}, fmt.Errorf("read notification intent stream lag snapshot: oldest entry: %w", err)
}
if len(messages) == 0 {
return telemetry.IntentStreamLagSnapshot{}, nil
}
oldestAt, err := streamEntryTime(messages[0].ID)
if err != nil {
return telemetry.IntentStreamLagSnapshot{}, fmt.Errorf("read notification intent stream lag snapshot: oldest entry id: %w", err)
}
return telemetry.IntentStreamLagSnapshot{
OldestUnprocessedAt: &oldestAt,
}, nil
}
func streamEntryTime(entryID string) (time.Time, error) {
timestampText, _, ok := strings.Cut(entryID, "-")
if !ok || strings.TrimSpace(timestampText) == "" {
return time.Time{}, fmt.Errorf("entry id %q is not a Redis Stream id", entryID)
}
timestampMS, err := strconv.ParseInt(timestampText, 10, 64)
if err != nil {
return time.Time{}, err
}
if timestampMS < 0 {
return time.Time{}, fmt.Errorf("entry id %q has negative timestamp", entryID)
}
return time.UnixMilli(timestampMS).UTC(), nil
}