snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
314
internal/api/consuming.go
Normal file
314
internal/api/consuming.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/apiproto"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// ConsumingHandlerConfig configures ConsumingHandler.
|
||||
type ConsumingHandlerConfig struct {
|
||||
UseOpenTelemetry bool
|
||||
}
|
||||
|
||||
// ConsumingHandler is responsible for processing API commands from asynchronous consumers.
|
||||
type ConsumingHandler struct {
|
||||
node *centrifuge.Node
|
||||
config ConsumingHandlerConfig
|
||||
api *Executor
|
||||
}
|
||||
|
||||
// NewConsumingHandler creates new ConsumingHandler.
|
||||
func NewConsumingHandler(n *centrifuge.Node, apiExecutor *Executor, c ConsumingHandlerConfig) *ConsumingHandler {
|
||||
h := &ConsumingHandler{
|
||||
node: n,
|
||||
config: c,
|
||||
api: apiExecutor,
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func logNonRetryableConsumingError(err error, method string) {
|
||||
log.Error().Err(err).Str("method", method).Msg("non retryable error during consuming, skip message")
|
||||
}
|
||||
|
||||
// ErrShutdown is returned to consumers for messages received after Node shutdown
|
||||
// started. Such messages must not be acknowledged: this node is no longer able to
|
||||
// deliver them, and depending on the broker a publish may still return success (a
|
||||
// memory broker never fails, so without this check a message would be committed
|
||||
// while going nowhere). Returning an error makes consumers leave the message
|
||||
// unacknowledged, so it's redelivered to another node or after restart.
|
||||
//
|
||||
// It wraps context.Canceled since consumers treat cancellation as a stop signal
|
||||
// and give up immediately instead of retrying with backoff until the shutdown
|
||||
// timeout is reached.
|
||||
var ErrShutdown = fmt.Errorf("node is shutting down: %w", context.Canceled)
|
||||
|
||||
type Dispatcher struct {
|
||||
handler *ConsumingHandler
|
||||
}
|
||||
|
||||
func NewDispatcher(handler *ConsumingHandler) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
// shuttingDown returns true once Node shutdown started.
|
||||
func (d *Dispatcher) shuttingDown() bool {
|
||||
select {
|
||||
case <-d.handler.node.NotifyShutdown():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Publish(ctx context.Context, req *apiproto.PublishRequest) error {
|
||||
if d.shuttingDown() {
|
||||
return ErrShutdown
|
||||
}
|
||||
resp := d.handler.api.Publish(ctx, req)
|
||||
if d.handler.config.UseOpenTelemetry && resp.Error != nil {
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.SetStatus(codes.Error, resp.Error.Error())
|
||||
}
|
||||
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
|
||||
return resp.Error
|
||||
}
|
||||
if resp.Error != nil {
|
||||
logNonRetryableConsumingError(resp.Error, "publish")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Broadcast(ctx context.Context, req *apiproto.BroadcastRequest) error {
|
||||
if d.shuttingDown() {
|
||||
return ErrShutdown
|
||||
}
|
||||
resp := d.handler.api.Broadcast(ctx, req)
|
||||
if d.handler.config.UseOpenTelemetry && resp.Error != nil {
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.SetStatus(codes.Error, resp.Error.Error())
|
||||
}
|
||||
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
|
||||
return resp.Error
|
||||
}
|
||||
if resp.Error != nil {
|
||||
logNonRetryableConsumingError(resp.Error, "broadcast")
|
||||
return nil
|
||||
}
|
||||
for _, response := range resp.Result.Responses {
|
||||
if response.Error != nil && response.Error.Code == apiproto.ErrorInternal.Code {
|
||||
// Any internal error in any channel response will result into a retry by a consumer.
|
||||
// To prevent duplicate messages publishers may use idempotency keys.
|
||||
return response.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConsumedPublication struct {
|
||||
Data []byte
|
||||
// IdempotencyKey is used to prevent duplicate messages.
|
||||
IdempotencyKey string
|
||||
// Delta is used to indicate that the message is a delta update.
|
||||
Delta bool
|
||||
// Tags are used to attach metadata to the message.
|
||||
Tags map[string]string
|
||||
// Version of publication.
|
||||
Version uint64
|
||||
// VersionEpoch of publication.
|
||||
VersionEpoch string
|
||||
}
|
||||
|
||||
func (d *Dispatcher) DispatchPublication(
|
||||
ctx context.Context, channels []string, pub ConsumedPublication,
|
||||
) error {
|
||||
if d.shuttingDown() {
|
||||
return ErrShutdown
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(channels) == 1 {
|
||||
req := &apiproto.PublishRequest{
|
||||
Data: pub.Data,
|
||||
Channel: channels[0],
|
||||
IdempotencyKey: pub.IdempotencyKey,
|
||||
Delta: pub.Delta,
|
||||
Tags: pub.Tags,
|
||||
Version: pub.Version,
|
||||
VersionEpoch: pub.VersionEpoch,
|
||||
}
|
||||
return d.Publish(ctx, req)
|
||||
}
|
||||
req := &apiproto.BroadcastRequest{
|
||||
Data: pub.Data,
|
||||
Channels: channels,
|
||||
IdempotencyKey: pub.IdempotencyKey,
|
||||
Delta: pub.Delta,
|
||||
Tags: pub.Tags,
|
||||
Version: pub.Version,
|
||||
VersionEpoch: pub.VersionEpoch,
|
||||
}
|
||||
return d.Broadcast(ctx, req)
|
||||
}
|
||||
|
||||
// Dispatch processes commands received from asynchronous consumers.
|
||||
func (d *Dispatcher) dispatchMethodPayload(ctx context.Context, method string, payload []byte) error {
|
||||
switch method {
|
||||
case "publish":
|
||||
_, err := d.handler.handlePublish(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "broadcast":
|
||||
// This one is special as we need to iterate over responses. Ideally we want to use
|
||||
// code gen for Dispatch but need to make sure that broadcast logic is preserved.
|
||||
res, err := d.handler.handleBroadcast(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
for _, resp := range res.Responses {
|
||||
if resp.Error != nil && resp.Error.Code == apiproto.ErrorInternal.Code {
|
||||
// Any internal error in any channel response will result into a retry by a consumer.
|
||||
// To prevent duplicate messages publishers may use idempotency keys.
|
||||
return resp.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "subscribe":
|
||||
_, err := d.handler.handleSubscribe(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "unsubscribe":
|
||||
_, err := d.handler.handleUnsubscribe(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "disconnect":
|
||||
_, err := d.handler.handleDisconnect(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "history_remove":
|
||||
_, err := d.handler.handleHistoryRemove(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "refresh":
|
||||
_, err := d.handler.handleRefresh(ctx, payload)
|
||||
if err != nil {
|
||||
var apiError *apiproto.Error
|
||||
if errors.As(err, &apiError) && apiError.Code == apiproto.ErrorInternal.Code {
|
||||
return err
|
||||
}
|
||||
logNonRetryableConsumingError(err, method)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
// Skip unsupported.
|
||||
log.Info().Msg("skip unsupported API command method")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var ErrInvalidData = errors.New("invalid data")
|
||||
|
||||
// JSONRawOrString can decode payload from bytes and from JSON string. This gives
|
||||
// us better interoperability. For example, JSONB field is encoded as JSON string in
|
||||
// Debezium PostgreSQL connector.
|
||||
type JSONRawOrString json.RawMessage
|
||||
|
||||
func (j *JSONRawOrString) UnmarshalJSON(data []byte) error {
|
||||
if len(data) > 0 && data[0] == '"' {
|
||||
// Unmarshal as a string, then convert the string to json.RawMessage.
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
*j = JSONRawOrString(str)
|
||||
} else {
|
||||
// Unmarshal directly as json.RawMessage
|
||||
*j = data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON returns m as the JSON encoding of m.
|
||||
func (j JSONRawOrString) MarshalJSON() ([]byte, error) {
|
||||
if j == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
type MethodWithRequestPayload struct {
|
||||
Method string `json:"method"`
|
||||
Payload JSONRawOrString `json:"payload"`
|
||||
}
|
||||
|
||||
// DispatchCommand processes commands received from asynchronous consumers.
|
||||
func (d *Dispatcher) DispatchCommand(ctx context.Context, method string, payload []byte) error {
|
||||
if d.shuttingDown() {
|
||||
return ErrShutdown
|
||||
}
|
||||
if method != "" {
|
||||
// If method already known – we can skip decoding into MethodWithRequestPayload.
|
||||
return d.dispatchMethodPayload(ctx, method, payload)
|
||||
}
|
||||
// Otherwise we expect payload to be MethodWithRequestPayload.
|
||||
var e MethodWithRequestPayload
|
||||
err := json.Unmarshal(payload, &e)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("skip malformed consumed message")
|
||||
return nil
|
||||
}
|
||||
return d.dispatchMethodPayload(ctx, e.Method, e.Payload)
|
||||
}
|
||||
Reference in New Issue
Block a user