239 lines
7.0 KiB
Go
239 lines
7.0 KiB
Go
package consuming
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/centrifugal/centrifugo/v6/internal/metrics"
|
|
"github.com/centrifugal/centrifugo/v6/internal/api"
|
|
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
|
"github.com/centrifugal/centrifugo/v6/internal/logging"
|
|
"github.com/centrifugal/centrifugo/v6/internal/redisqueue"
|
|
"github.com/centrifugal/centrifugo/v6/internal/redisshard"
|
|
|
|
"github.com/redis/rueidis"
|
|
)
|
|
|
|
type RedisStreamConsumerConfig = configtypes.RedisStreamConsumerConfig
|
|
|
|
// RedisStreamConsumer consumes messages from a Redis Stream.
|
|
type RedisStreamConsumer struct {
|
|
config RedisStreamConsumerConfig
|
|
dispatcher Dispatcher
|
|
consumers map[string]*redisqueue.Consumer
|
|
common *consumerCommon
|
|
}
|
|
|
|
// NewRedisStreamConsumer creates a new Redis Streams consumer.
|
|
func NewRedisStreamConsumer(
|
|
cfg RedisStreamConsumerConfig,
|
|
dispatcher Dispatcher,
|
|
common *consumerCommon,
|
|
) (*RedisStreamConsumer, error) {
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
shards, err := redisshard.BuildRedisShards(cfg.Redis)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build Redis shards: %w", err)
|
|
}
|
|
if len(shards) != 1 {
|
|
return nil, fmt.Errorf("expected a single Redis shard, got %d", len(shards))
|
|
}
|
|
shard := shards[0]
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
|
|
return client.Do(ctx, client.B().Ping().Build())
|
|
}).Error() != nil {
|
|
return nil, errors.New("failed to ping Redis")
|
|
}
|
|
|
|
consumer := &RedisStreamConsumer{
|
|
config: cfg,
|
|
dispatcher: dispatcher,
|
|
common: common,
|
|
}
|
|
consumers := make(map[string]*redisqueue.Consumer)
|
|
for _, stream := range cfg.Streams {
|
|
streamConsumer, err := redisqueue.NewConsumer(shard, redisqueue.ConsumerOptions{
|
|
Stream: stream,
|
|
ConsumerFunc: consumer.process,
|
|
Name: common.nodeID,
|
|
GroupName: cfg.ConsumerGroup,
|
|
VisibilityTimeout: cfg.VisibilityTimeout.ToDuration(),
|
|
BlockingTimeout: 5 * time.Second,
|
|
ReclaimInterval: 5 * time.Second,
|
|
Concurrency: cfg.NumWorkers,
|
|
UseLegacyReclaim: false,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create consumer for stream %q: %w", stream, err)
|
|
}
|
|
consumers[stream] = streamConsumer
|
|
}
|
|
consumer.consumers = consumers
|
|
return consumer, nil
|
|
}
|
|
|
|
// process is the ConsumerFunc for redisqueue.Consumer.
|
|
func (c *RedisStreamConsumer) process(msg *redisqueue.Message) error {
|
|
if logging.Enabled(logging.DebugLevel) {
|
|
c.common.log.Debug().Str("stream", msg.ID).
|
|
Msg("received message from stream")
|
|
}
|
|
dataStr, ok := msg.Values[c.config.PayloadValue]
|
|
if !ok {
|
|
c.common.log.Error().
|
|
Str("expected_value", c.config.PayloadValue).
|
|
Msg("payload value not found in redis stream message")
|
|
return nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var err error
|
|
if c.config.PublicationDataMode.Enabled {
|
|
err = c.processPublicationDataMessage(ctx, msg, []byte(dataStr))
|
|
} else {
|
|
err = c.processCommandMessage(ctx, msg, []byte(dataStr))
|
|
}
|
|
if err != nil {
|
|
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
|
|
c.common.log.Error().Err(err).Msg("error processing redis stream message")
|
|
} else {
|
|
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Run starts the consumer loop by starting each underlying consumer in a separate goroutine.
|
|
// It also listens for context cancellation to gracefully shutdown all consumers.
|
|
func (c *RedisStreamConsumer) Run(ctx context.Context) error {
|
|
shutdownCh := make(chan struct{})
|
|
|
|
for stream, consumer := range c.consumers {
|
|
errCh := make(chan error, 64)
|
|
consumer.Errors = errCh
|
|
go func() {
|
|
for {
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
c.common.log.Error().Str("stream", stream).Err(err).Msg("error from consumer")
|
|
}
|
|
case <-shutdownCh:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
go consumer.Run()
|
|
}
|
|
|
|
go func() {
|
|
defer close(shutdownCh)
|
|
<-ctx.Done()
|
|
for _, consumer := range c.consumers {
|
|
consumer.Shutdown()
|
|
}
|
|
}()
|
|
<-ctx.Done()
|
|
<-shutdownCh
|
|
return ctx.Err()
|
|
}
|
|
|
|
// processPublicationDataMessage processes a message in publication data mode.
|
|
func (c *RedisStreamConsumer) processPublicationDataMessage(ctx context.Context, msg *redisqueue.Message, data []byte) error {
|
|
idempotencyKey := getStringProperty(msg, c.config.PublicationDataMode.IdempotencyKeyValue)
|
|
delta, err := getBoolProperty(msg, c.config.PublicationDataMode.DeltaValue)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing delta property %q: %w", c.config.PublicationDataMode.DeltaValue, err)
|
|
}
|
|
channelsStr := getStringProperty(msg, c.config.PublicationDataMode.ChannelsValue)
|
|
channels := strings.Split(channelsStr, ",")
|
|
if len(channels) == 0 || (len(channels) == 1 && channels[0] == "") {
|
|
c.common.log.Info().Msg("no channels found, skipping message")
|
|
return nil
|
|
}
|
|
tags := getTagsFromRedisValues(msg, c.config.PublicationDataMode.TagsValuePrefix)
|
|
version, err := getUint64Property(msg, c.config.PublicationDataMode.VersionValue)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing version property %q: %w", c.config.PublicationDataMode.VersionValue, err)
|
|
}
|
|
return c.dispatcher.DispatchPublication(ctx, channels, api.ConsumedPublication{
|
|
Data: data,
|
|
IdempotencyKey: idempotencyKey,
|
|
Delta: delta,
|
|
Tags: tags,
|
|
Version: version,
|
|
VersionEpoch: getStringProperty(msg, c.config.PublicationDataMode.VersionEpochValue),
|
|
})
|
|
}
|
|
|
|
// processCommandMessage processes a message in command mode.
|
|
func (c *RedisStreamConsumer) processCommandMessage(ctx context.Context, msg *redisqueue.Message, data []byte) error {
|
|
method := getStringProperty(msg, c.config.MethodValue)
|
|
return c.dispatcher.DispatchCommand(ctx, method, data)
|
|
}
|
|
|
|
// getStringProperty extracts a string property from the message values.
|
|
func getStringProperty(msg *redisqueue.Message, key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
return msg.Values[key]
|
|
}
|
|
|
|
func getUint64Property(msg *redisqueue.Message, key string) (uint64, error) {
|
|
if key == "" {
|
|
return 0, nil
|
|
}
|
|
val, ok := msg.Values[key]
|
|
if !ok {
|
|
return 0, nil
|
|
}
|
|
i, err := strconv.ParseUint(val, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error parsing uint64 property %q: %w", key, err)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func getBoolProperty(msg *redisqueue.Message, key string) (bool, error) {
|
|
if key == "" {
|
|
return false, nil
|
|
}
|
|
val, ok := msg.Values[key]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
b, err := strconv.ParseBool(val)
|
|
if err != nil {
|
|
return false, fmt.Errorf("error parsing bool property %q: %w", key, err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// getTagsFromRedisValues extracts tag values from message values with the specified prefix.
|
|
func getTagsFromRedisValues(msg *redisqueue.Message, prefix string) map[string]string {
|
|
var tags map[string]string
|
|
if prefix == "" {
|
|
return tags
|
|
}
|
|
for k, v := range msg.Values {
|
|
if strings.HasPrefix(k, prefix) {
|
|
if tags == nil {
|
|
tags = make(map[string]string)
|
|
}
|
|
tags[strings.TrimPrefix(k, prefix)] = v
|
|
}
|
|
}
|
|
return tags
|
|
}
|