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 }