snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

View File

@@ -0,0 +1,419 @@
package consuming
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"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/metrics"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus"
)
type AzureServiceBusConsumerConfig = configtypes.AzureServiceBusConsumerConfig
// AzureServiceBusConsumer consumes messages from Azure Service Bus.
type AzureServiceBusConsumer struct {
config AzureServiceBusConsumerConfig
dispatcher Dispatcher
client *azservicebus.Client
common *consumerCommon
}
// NewAzureServiceBusConsumer creates and initializes a new AzureServiceBusConsumer.
func NewAzureServiceBusConsumer(
cfg AzureServiceBusConsumerConfig,
dispatcher Dispatcher,
common *consumerCommon,
) (*AzureServiceBusConsumer, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
var client *azservicebus.Client
var err error
if cfg.UseAzureIdentity {
cred, err := azidentity.NewClientSecretCredential(cfg.TenantID, cfg.ClientID, cfg.ClientSecret, nil)
if err != nil {
return nil, fmt.Errorf("failed to obtain Azure credential: %w", err)
}
client, err = azservicebus.NewClient(cfg.FullyQualifiedNamespace, cred, nil)
if err != nil {
return nil, fmt.Errorf("failed to create Azure Service Bus client: %w", err)
}
} else {
client, err = azservicebus.NewClientFromConnectionString(cfg.ConnectionString, nil)
if err != nil {
return nil, fmt.Errorf("failed to create client from connection string: %w", err)
}
}
return &AzureServiceBusConsumer{
config: cfg,
dispatcher: dispatcher,
client: client,
common: common,
}, nil
}
// Run starts the consumer until the context is canceled.
func (c *AzureServiceBusConsumer) Run(ctx context.Context) error {
if c.config.UseSessions {
return c.runSessionMode(ctx)
}
return c.runNonSessionMode(ctx)
}
// runSessionMode consumes messages in session mode using a separate goroutine per queue worker.
func (c *AzureServiceBusConsumer) runSessionMode(ctx context.Context) error {
var wg sync.WaitGroup
// For each queue, spawn workers.
for _, queue := range c.config.Queues {
for i := 0; i < c.config.MaxConcurrentCalls; i++ {
wg.Add(1)
go func(queueName string) {
defer wg.Done()
for {
// Respect context cancellation.
if ctx.Err() != nil {
return
}
sr, err := c.client.AcceptNextSessionForQueue(ctx, queueName, nil)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
c.common.log.Error().Err(err).Msgf("failed to accept session for queue %s", queueName)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
// Process the accepted session.
c.processSession(ctx, sr, queueName)
}
}(queue)
}
}
wg.Wait()
return ctx.Err()
}
// processSession processes messages for a given session and renews the session lock periodically.
// The renewal interval is set to 2/3 of the remaining time until the session lock expires.
// The context passed to message processing is bound to the session lifetime.
func (c *AzureServiceBusConsumer) processSession(ctx context.Context, sr *azservicebus.SessionReceiver, queueName string) {
// Create a session-specific context that will be canceled when the session should end.
sessionCtx, cancelSession := context.WithCancel(ctx)
defer cancelSession()
// Derive a context for lock renewal from the session context.
renewCtx, cancelRenew := context.WithCancel(sessionCtx)
defer cancelRenew()
// Start a goroutine to renew the session lock at 2/3 of the remaining time until expiration.
go func() {
for {
select {
case <-renewCtx.Done():
return
default:
}
// Calculate remaining time until lock expiration.
lockUntil := sr.LockedUntil() // sr.LockedUntil() returns the time the session is locked until.
remaining := time.Until(lockUntil)
renewalInterval := time.Duration(float64(remaining) * (2.0 / 3.0))
if renewalInterval <= 0 {
c.common.log.Error().Msgf("session lock already expired for queue %s", queueName)
cancelSession()
return
}
// Wait for the computed renewal interval.
select {
case <-time.After(renewalInterval):
// Attempt to renew the session lock.
if err := sr.RenewSessionLock(renewCtx, nil); err != nil {
c.common.log.Error().Err(err).Msgf("failed to renew session lock for queue %s", queueName)
// On error, cancel the session to abort processing.
cancelSession()
return
} else {
c.common.log.Debug().Msgf("renewed session lock for queue %s", queueName)
}
case <-renewCtx.Done():
return
}
}
}()
// Ensure the session is closed when processing ends.
defer func() {
if err := sr.Close(sessionCtx); err != nil {
c.common.log.Error().Err(err).Msgf("failed to close session for queue %s", queueName)
}
}()
// Process messages using the session-bound context.
for {
if sessionCtx.Err() != nil {
return
}
messages, err := sr.ReceiveMessages(sessionCtx, c.config.MaxReceiveMessages, nil)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
c.common.log.Error().Err(err).Msgf("error receiving messages for session on queue %s", queueName)
return
}
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("queue", queueName).Int("num_messages", len(messages)).
Msg("received messages from queue")
}
if len(messages) == 0 {
// No messages – the session is ended.
return
}
for _, msg := range messages {
c.processMessage(sessionCtx, msg, sr)
}
}
}
// runNonSessionMode consumes messages without sessions.
func (c *AzureServiceBusConsumer) runNonSessionMode(ctx context.Context) error {
var wg sync.WaitGroup
// For each queue, spawn receivers.
for _, queue := range c.config.Queues {
for i := 0; i < c.config.MaxConcurrentCalls; i++ {
receiver, err := c.client.NewReceiverForQueue(queue, nil)
if err != nil {
return fmt.Errorf("failed to create receiver for queue %s: %w", queue, err)
}
wg.Add(1)
go func(r *azservicebus.Receiver, queueName string) {
defer wg.Done()
defer func() {
if err := r.Close(ctx); err != nil {
c.common.log.Error().Err(err).Msgf("failed to close receiver for queue %s", queueName)
}
}()
for {
if ctx.Err() != nil {
return
}
messages, err := r.ReceiveMessages(ctx, c.config.MaxReceiveMessages, nil)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
c.common.log.Error().Err(err).Msgf("error receiving messages from queue %s", queueName)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("queue", queueName).Int("num_messages", len(messages)).
Msg("received messages from queue")
}
for _, msg := range messages {
c.processMessage(ctx, msg, r)
}
}
}(receiver, queue)
}
}
wg.Wait()
return ctx.Err()
}
// azureCompleter defines an interface for completing messages.
type azureCompleter interface {
CompleteMessage(context.Context, *azservicebus.ReceivedMessage, *azservicebus.CompleteMessageOptions) error
AbandonMessage(context.Context, *azservicebus.ReceivedMessage, *azservicebus.AbandonMessageOptions) error
}
// processMessage processes a single message with a retry mechanism.
func (c *AzureServiceBusConsumer) processMessage(ctx context.Context, msg *azservicebus.ReceivedMessage, completer azureCompleter) {
var retries int
var backoffDuration time.Duration
data := msg.Body
const maxRetries = 5
var processErr error
for {
if c.config.PublicationDataMode.Enabled {
processErr = c.processPublicationDataMessage(ctx, msg, data)
} else {
processErr = c.processCommandMessage(ctx, msg, data)
}
if processErr == nil {
if retries > 0 {
c.common.log.Info().Msg("message processed successfully after retries")
}
break
}
// Stop retrying if context is canceled or maximum attempts reached.
if ctx.Err() != nil || retries >= maxRetries {
c.common.log.Error().Err(processErr).Msg("max retries reached or context canceled; abandoning message")
break
}
retries++
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
c.common.log.Error().Err(processErr).Msgf("error processing message, retrying in %v", backoffDuration)
select {
case <-time.After(backoffDuration):
case <-ctx.Done():
return
}
}
if processErr != nil {
// Processing failed after retries: abandon the message so the broker
// redelivers it (and eventually dead-letters at MaxDeliveryCount) instead
// of completing it, which settles it as successful and permanently drops it.
if err := completer.AbandonMessage(ctx, msg, nil); err != nil {
c.common.log.Error().Err(err).Msg("failed to abandon message")
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
}
return
}
// Complete the message (or log an error on failure).
if err := completer.CompleteMessage(ctx, msg, nil); err != nil {
c.common.log.Error().Err(err).Msg("failed to complete message")
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
} else {
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
}
}
// processPublicationDataMessage handles messages in publication data mode.
func (c *AzureServiceBusConsumer) processPublicationDataMessage(ctx context.Context, msg *azservicebus.ReceivedMessage, data []byte) error {
idempotencyKey, _ := getProperty(msg, c.config.PublicationDataMode.IdempotencyKeyProperty)
delta, err := getAzureBoolProperty(msg, c.config.PublicationDataMode.DeltaProperty)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing delta property, skipping message")
return nil
}
channelsStr, _ := getProperty(msg, c.config.PublicationDataMode.ChannelsProperty)
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 := getTagsFromProperties(msg, c.config.PublicationDataMode.TagsPropertyPrefix)
version, err := getAzureUint64Property(msg, c.config.PublicationDataMode.VersionProperty)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing version property, skipping message")
return nil
}
versionEpoch, _ := getProperty(msg, c.config.PublicationDataMode.VersionEpochProperty)
return c.dispatcher.DispatchPublication(ctx, channels, api.ConsumedPublication{
Data: data,
IdempotencyKey: idempotencyKey,
Delta: delta,
Tags: tags,
Version: version,
VersionEpoch: versionEpoch,
})
}
// processCommandMessage handles messages in command mode.
func (c *AzureServiceBusConsumer) processCommandMessage(ctx context.Context, msg *azservicebus.ReceivedMessage, data []byte) error {
method, _ := getProperty(msg, c.config.MethodProperty)
return c.dispatcher.DispatchCommand(ctx, method, data)
}
func getAzureUint64Property(msg *azservicebus.ReceivedMessage, key string) (uint64, error) {
if key == "" {
return 0, nil
}
val, ok := msg.ApplicationProperties[key]
if !ok {
return 0, nil
}
strVal, ok := val.(string)
if !ok {
return 0, fmt.Errorf("property %q is not a string", key)
}
uintVal, err := strconv.ParseUint(strVal, 10, 64)
if err != nil {
return 0, fmt.Errorf("error parsing uint64 property %q: %w", key, err)
}
return uintVal, nil
}
func getAzureBoolProperty(msg *azservicebus.ReceivedMessage, key string) (bool, error) {
if key == "" {
return false, nil
}
val, ok := msg.ApplicationProperties[key]
if !ok {
return false, nil
}
strVal, ok := val.(string)
if !ok {
return false, fmt.Errorf("property %q is not a string", key)
}
b, err := strconv.ParseBool(strVal)
if err != nil {
return false, fmt.Errorf("error parsing bool property %q: %w", key, err)
}
return b, nil
}
// getProperty extracts a string property from the message's ApplicationProperties.
func getProperty(msg *azservicebus.ReceivedMessage, key string) (string, bool) {
if key == "" {
return "", false
}
if val, ok := msg.ApplicationProperties[key]; ok {
if str, ok := val.(string); ok {
return str, true
}
}
return "", false
}
// getTagsFromProperties extracts tag values from message properties using the given prefix.
func getTagsFromProperties(msg *azservicebus.ReceivedMessage, prefix string) map[string]string {
var tags map[string]string
if prefix == "" {
return tags
}
for k, v := range msg.ApplicationProperties {
if strings.HasPrefix(k, prefix) {
if str, ok := v.(string); ok {
if tags == nil {
tags = make(map[string]string)
}
tags[strings.TrimPrefix(k, prefix)] = str
}
}
}
return tags
}