package redisstate import ( "bytes" "encoding/json" "fmt" "io" "time" ) // StreamOffset stores the persisted progress of the plain-XREAD intent // consumer. type StreamOffset struct { // Stream stores the Redis Stream name. Stream string // LastProcessedEntryID stores the last durably processed Redis Stream // entry identifier. LastProcessedEntryID string // UpdatedAt stores when the offset record was last updated. UpdatedAt time.Time } // Validate reports whether offset contains a complete persisted consumer // progress record. func (offset StreamOffset) Validate() error { if offset.Stream == "" { return fmt.Errorf("stream offset stream must not be empty") } if offset.LastProcessedEntryID == "" { return fmt.Errorf("stream offset last processed entry id must not be empty") } if offset.UpdatedAt.IsZero() { return fmt.Errorf("stream offset updated at must not be zero") } if !offset.UpdatedAt.Equal(offset.UpdatedAt.UTC()) { return fmt.Errorf("stream offset updated at must be UTC") } if !offset.UpdatedAt.Equal(offset.UpdatedAt.Truncate(time.Millisecond)) { return fmt.Errorf("stream offset updated at must use millisecond precision") } return nil } type streamOffsetJSON struct { Stream string `json:"stream"` LastProcessedEntryID string `json:"last_processed_entry_id"` UpdatedAtMS int64 `json:"updated_at_ms"` } // MarshalStreamOffset marshals one stream-offset record into the strict JSON // representation owned by Notification Service. func MarshalStreamOffset(offset StreamOffset) ([]byte, error) { if err := offset.Validate(); err != nil { return nil, fmt.Errorf("marshal stream offset: %w", err) } return marshalStrictJSON(streamOffsetJSON{ Stream: offset.Stream, LastProcessedEntryID: offset.LastProcessedEntryID, UpdatedAtMS: offset.UpdatedAt.UTC().UnixMilli(), }) } // UnmarshalStreamOffset unmarshals one strict JSON stream-offset record. func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) { var wire streamOffsetJSON if err := unmarshalStrictJSON(payload, &wire); err != nil { return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err) } offset := StreamOffset{ Stream: wire.Stream, LastProcessedEntryID: wire.LastProcessedEntryID, UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(), } if err := offset.Validate(); err != nil { return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err) } return offset, nil } func marshalStrictJSON(value any) ([]byte, error) { return json.Marshal(value) } func unmarshalStrictJSON(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewBuffer(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return fmt.Errorf("unexpected trailing JSON input") } return err } return nil }