initial commit
This commit is contained in:
227
pkg/rabbit/client.go
Normal file
227
pkg/rabbit/client.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"base/pkg/metrics"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
connectionManager ConnectionManager
|
||||
publisher Publisher
|
||||
consumers []Consumer
|
||||
consumersMutex sync.RWMutex
|
||||
config *Config
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewClient(config *Config, logger zerolog.Logger, metric *metrics.Metrics) (Client, error) {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
config.ApplyDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
connMgr, err := NewConnectionManager(config, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection manager: %w", err)
|
||||
}
|
||||
|
||||
c := &client{
|
||||
connectionManager: connMgr,
|
||||
publisher: NewPublisher(connMgr, config, logger, metric),
|
||||
consumers: make([]Consumer, 0),
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *client) Publisher() Publisher {
|
||||
return c.publisher
|
||||
}
|
||||
|
||||
func (c *client) RegisterConsumer(handler MessageHandler, opts *ConsumerOptions) Consumer {
|
||||
newConsumer := NewConsumer(c.connectionManager, handler, opts, c.logger)
|
||||
|
||||
c.consumersMutex.Lock()
|
||||
c.consumers = append(c.consumers, newConsumer)
|
||||
c.consumersMutex.Unlock()
|
||||
|
||||
c.logger.Info().Msgf("registered consumer with options: %v", opts)
|
||||
return newConsumer
|
||||
}
|
||||
|
||||
func (c *client) DeclareExchange(name string, opts ExchangeOptions) error {
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConnectionError("get channel for exchange declaration", err)
|
||||
}
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
err = ch.ExchangeDeclare(
|
||||
name,
|
||||
opts.Type,
|
||||
opts.Durable,
|
||||
opts.AutoDelete,
|
||||
opts.Internal,
|
||||
opts.NoWait,
|
||||
opts.Args,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to declare exchange '%s': %w", name, err)
|
||||
}
|
||||
|
||||
c.logger.Info().Str("exchange", name).
|
||||
Str("type", opts.Type).
|
||||
Msg("Exchange declared successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) DeclareQueue(name string, opts QueueOptions) error {
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConnectionError("get channel for queue declaration", err)
|
||||
}
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
args := amqp.Table{}
|
||||
if opts.Args != nil {
|
||||
for k, v := range opts.Args {
|
||||
args[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
_, err = ch.QueueDeclare(
|
||||
name,
|
||||
opts.Durable,
|
||||
opts.AutoDelete,
|
||||
opts.Exclusive,
|
||||
opts.NoWait,
|
||||
args,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to declare queue '%s': %w", name, err)
|
||||
}
|
||||
|
||||
c.logger.Info().Msgf("Queue declared successfully: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) BindQueue(queue, exchange, routingKey string) error {
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConnectionError("get channel for queue binding", err)
|
||||
}
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
err = ch.QueueBind(
|
||||
queue,
|
||||
routingKey,
|
||||
exchange,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind queue '%s' to exchange '%s' with routing key '%s': %w", queue, exchange, routingKey, err)
|
||||
}
|
||||
|
||||
c.logger.Info().Msgf("Queue binded successfully: %s", queue)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) DeleteQueue(name string) error {
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConnectionError("get channel for queue deletion", err)
|
||||
}
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
_, err = ch.QueueDelete(
|
||||
name,
|
||||
false, // ifUnused
|
||||
false, // ifEmpty
|
||||
false, // noWait
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete queue '%s': %w", name, err)
|
||||
}
|
||||
|
||||
c.logger.Info().Msgf("Queue deleted successfully: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) DeleteExchange(name string) error {
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConnectionError("get channel for exchange deletion", err)
|
||||
}
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
err = ch.ExchangeDelete(
|
||||
name,
|
||||
false, // ifUnused
|
||||
false, // noWait
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete exchange '%s': %w", name, err)
|
||||
}
|
||||
|
||||
c.logger.Info().Msgf("Exchange deleted successfully: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) HealthCheck() error {
|
||||
if !c.connectionManager.IsConnected() {
|
||||
return ErrConnectionLost
|
||||
}
|
||||
|
||||
// Try to get a channel and perform a basic operation
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConnectionError("health check channel creation", err)
|
||||
}
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) Close() error {
|
||||
c.logger.Info().Msg("Closing RabbitMQ client...")
|
||||
|
||||
var closeErrors []error
|
||||
|
||||
if err := c.publisher.Close(); err != nil {
|
||||
closeErrors = append(closeErrors, fmt.Errorf("publisher close error: %w", err))
|
||||
}
|
||||
|
||||
// Close all additional consumers
|
||||
c.consumersMutex.Lock()
|
||||
for i, consumer := range c.consumers {
|
||||
if err := consumer.Close(); err != nil {
|
||||
closeErrors = append(closeErrors, fmt.Errorf("consumer %d close error: %w", i, err))
|
||||
}
|
||||
}
|
||||
c.consumers = nil // Clear the slice
|
||||
c.consumersMutex.Unlock()
|
||||
|
||||
if err := c.connectionManager.Close(); err != nil {
|
||||
closeErrors = append(closeErrors, fmt.Errorf("connection manager close error: %w", err))
|
||||
}
|
||||
|
||||
if len(closeErrors) > 0 {
|
||||
return fmt.Errorf("errors during close: %v", closeErrors)
|
||||
}
|
||||
|
||||
c.logger.Info().Msg("RabbitMQ client closed successfully")
|
||||
return nil
|
||||
}
|
||||
225
pkg/rabbit/config.go
Normal file
225
pkg/rabbit/config.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Connection settings
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
VHost string `json:"vhost"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
|
||||
// Connection pool settings
|
||||
MaxConnections int `json:"max_connections"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
ConnectionTimeout time.Duration `json:"connection_timeout"`
|
||||
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
||||
|
||||
// Reconnection settings
|
||||
ReconnectDelay time.Duration `json:"reconnect_delay"`
|
||||
MaxReconnectDelay time.Duration `json:"max_reconnect_delay"`
|
||||
ReconnectAttempts int `json:"reconnect_attempts"`
|
||||
EnableAutoReconnect bool `json:"enable_auto_reconnect"`
|
||||
|
||||
// Publisher settings
|
||||
PublisherConfig PublisherOptions `json:"publisher_config"`
|
||||
|
||||
// Health check settings
|
||||
HealthCheckInterval time.Duration `json:"health_check_interval"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Host: "localhost",
|
||||
Port: 5672,
|
||||
Username: "guest",
|
||||
Password: "guest",
|
||||
VHost: "/",
|
||||
UseTLS: false,
|
||||
MaxConnections: 10,
|
||||
MaxChannels: 100,
|
||||
ConnectionTimeout: 30 * time.Second,
|
||||
HeartbeatInterval: 60 * time.Second,
|
||||
ReconnectDelay: 5 * time.Second,
|
||||
MaxReconnectDelay: 5 * time.Minute,
|
||||
ReconnectAttempts: 10,
|
||||
EnableAutoReconnect: true,
|
||||
PublisherConfig: PublisherOptions{
|
||||
ConfirmMode: true,
|
||||
Mandatory: false,
|
||||
Immediate: false,
|
||||
RetryAttempts: 3,
|
||||
RetryDelay: 1 * time.Second,
|
||||
ConfirmTimeout: 10 * time.Second,
|
||||
},
|
||||
HealthCheckInterval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) BuildConnectionString() string {
|
||||
if c.URL != "" {
|
||||
return c.URL
|
||||
}
|
||||
|
||||
scheme := "amqp"
|
||||
if c.UseTLS {
|
||||
scheme = "amqps"
|
||||
}
|
||||
|
||||
// Build URL
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: fmt.Sprintf("%s:%d", c.Host, c.Port),
|
||||
Path: c.VHost,
|
||||
}
|
||||
|
||||
if c.Username != "" && c.Password != "" {
|
||||
u.User = url.UserPassword(c.Username, c.Password)
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.URL == "" {
|
||||
if c.Host == "" {
|
||||
return NewConfigurationError("host", c.Host, "host cannot be empty when URL is not provided")
|
||||
}
|
||||
if c.Port <= 0 || c.Port > 65535 {
|
||||
return NewConfigurationError("port", c.Port, "port must be between 1 and 65535")
|
||||
}
|
||||
} else {
|
||||
if _, err := url.Parse(c.URL); err != nil {
|
||||
return NewConfigurationError("url", c.URL, fmt.Sprintf("invalid URL format: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if c.MaxConnections <= 0 {
|
||||
return NewConfigurationError("max_connections", c.MaxConnections, "max_connections must be greater than 0")
|
||||
}
|
||||
|
||||
if c.MaxChannels <= 0 {
|
||||
return NewConfigurationError("max_channels", c.MaxChannels, "max_channels must be greater than 0")
|
||||
}
|
||||
|
||||
if c.ConnectionTimeout <= 0 {
|
||||
return NewConfigurationError("connection_timeout", c.ConnectionTimeout, "connection_timeout must be greater than 0")
|
||||
}
|
||||
|
||||
if c.HeartbeatInterval < 0 {
|
||||
return NewConfigurationError("heartbeat_interval", c.HeartbeatInterval, "heartbeat_interval cannot be negative")
|
||||
}
|
||||
|
||||
if c.ReconnectDelay <= 0 {
|
||||
return NewConfigurationError("reconnect_delay", c.ReconnectDelay, "reconnect_delay must be greater than 0")
|
||||
}
|
||||
|
||||
if c.MaxReconnectDelay < c.ReconnectDelay {
|
||||
return NewConfigurationError("max_reconnect_delay", c.MaxReconnectDelay, "max_reconnect_delay must be greater than or equal to reconnect_delay")
|
||||
}
|
||||
|
||||
if c.ReconnectAttempts < 0 {
|
||||
return NewConfigurationError("reconnect_attempts", c.ReconnectAttempts, "reconnect_attempts cannot be negative")
|
||||
}
|
||||
|
||||
if c.HealthCheckInterval < 0 {
|
||||
return NewConfigurationError("health_check_interval", c.HealthCheckInterval, "health_check_interval cannot be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validatePublisherConfig() error {
|
||||
if c.PublisherConfig.RetryAttempts < 0 {
|
||||
return NewConfigurationError("publisher.retry_attempts", c.PublisherConfig.RetryAttempts, "retry_attempts cannot be negative")
|
||||
}
|
||||
|
||||
if c.PublisherConfig.RetryDelay < 0 {
|
||||
return NewConfigurationError("publisher.retry_delay", c.PublisherConfig.RetryDelay, "retry_delay cannot be negative")
|
||||
}
|
||||
|
||||
if c.PublisherConfig.ConfirmTimeout <= 0 {
|
||||
return NewConfigurationError("publisher.confirm_timeout", c.PublisherConfig.ConfirmTimeout, "confirm_timeout must be greater than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ApplyDefaults() {
|
||||
defaults := DefaultConfig()
|
||||
|
||||
if c.Host == "" && c.URL == "" {
|
||||
c.Host = defaults.Host
|
||||
}
|
||||
if c.Port == 0 {
|
||||
c.Port = defaults.Port
|
||||
}
|
||||
if c.Username == "" {
|
||||
c.Username = defaults.Username
|
||||
}
|
||||
if c.Password == "" {
|
||||
c.Password = defaults.Password
|
||||
}
|
||||
if c.VHost == "" {
|
||||
c.VHost = defaults.VHost
|
||||
}
|
||||
if c.MaxConnections == 0 {
|
||||
c.MaxConnections = defaults.MaxConnections
|
||||
}
|
||||
if c.MaxChannels == 0 {
|
||||
c.MaxChannels = defaults.MaxChannels
|
||||
}
|
||||
if c.ConnectionTimeout == 0 {
|
||||
c.ConnectionTimeout = defaults.ConnectionTimeout
|
||||
}
|
||||
if c.HeartbeatInterval == 0 {
|
||||
c.HeartbeatInterval = defaults.HeartbeatInterval
|
||||
}
|
||||
if c.ReconnectDelay == 0 {
|
||||
c.ReconnectDelay = defaults.ReconnectDelay
|
||||
}
|
||||
if c.MaxReconnectDelay == 0 {
|
||||
c.MaxReconnectDelay = defaults.MaxReconnectDelay
|
||||
}
|
||||
if c.ReconnectAttempts == 0 {
|
||||
c.ReconnectAttempts = defaults.ReconnectAttempts
|
||||
}
|
||||
|
||||
if c.HealthCheckInterval == 0 {
|
||||
c.HealthCheckInterval = defaults.HealthCheckInterval
|
||||
}
|
||||
|
||||
// Apply publisher defaults
|
||||
if c.PublisherConfig.RetryAttempts == 0 {
|
||||
c.PublisherConfig.RetryAttempts = defaults.PublisherConfig.RetryAttempts
|
||||
}
|
||||
if c.PublisherConfig.RetryDelay == 0 {
|
||||
c.PublisherConfig.RetryDelay = defaults.PublisherConfig.RetryDelay
|
||||
}
|
||||
if c.PublisherConfig.ConfirmTimeout == 0 {
|
||||
c.PublisherConfig.ConfirmTimeout = defaults.PublisherConfig.ConfirmTimeout
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (c *Config) Clone() *Config {
|
||||
clone := *c
|
||||
|
||||
// Deep copy publisher config
|
||||
clone.PublisherConfig = c.PublisherConfig
|
||||
if c.PublisherConfig.Args != nil {
|
||||
clone.PublisherConfig.Args = make(map[string]interface{})
|
||||
for k, v := range c.PublisherConfig.Args {
|
||||
clone.PublisherConfig.Args[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return &clone
|
||||
}
|
||||
312
pkg/rabbit/connection.go
Normal file
312
pkg/rabbit/connection.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type connectionManager struct {
|
||||
config *Config
|
||||
connection *amqp.Connection
|
||||
channels []*amqp.Channel
|
||||
connectionMutex sync.RWMutex
|
||||
channelMutex sync.RWMutex
|
||||
channelPool chan *amqp.Channel
|
||||
isConnected int32 // atomic
|
||||
isReconnecting int32 // atomic
|
||||
shutdownCh chan struct{}
|
||||
connectionLossCh chan *amqp.Error
|
||||
logger zerolog.Logger
|
||||
reconnectAttempts int
|
||||
lastReconnectTime time.Time
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewConnectionManager(config *Config, logger zerolog.Logger) (ConnectionManager, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
cm := &connectionManager{
|
||||
config: config,
|
||||
shutdownCh: make(chan struct{}),
|
||||
connectionLossCh: make(chan *amqp.Error, 100),
|
||||
logger: logger,
|
||||
channelPool: make(chan *amqp.Channel, config.MaxChannels),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
if err := cm.connect(); err != nil {
|
||||
cancel()
|
||||
return nil, NewConnectionError("initial connection", err)
|
||||
}
|
||||
|
||||
if config.EnableAutoReconnect {
|
||||
cm.wg.Add(1)
|
||||
go cm.reconnectLoop()
|
||||
}
|
||||
|
||||
if config.HealthCheckInterval > 0 {
|
||||
cm.wg.Add(1)
|
||||
go cm.healthCheckLoop()
|
||||
}
|
||||
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
func (cm *connectionManager) GetConnection() (*amqp.Connection, error) {
|
||||
cm.connectionMutex.RLock()
|
||||
defer cm.connectionMutex.RUnlock()
|
||||
|
||||
if cm.connection == nil || cm.connection.IsClosed() {
|
||||
return nil, ErrConnectionLost
|
||||
}
|
||||
|
||||
return cm.connection, nil
|
||||
}
|
||||
|
||||
func (cm *connectionManager) GetChannel() (*amqp.Channel, error) {
|
||||
// Try to get from pool first
|
||||
select {
|
||||
case ch := <-cm.channelPool:
|
||||
if ch != nil && !ch.IsClosed() {
|
||||
return ch, nil
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
// Create new channel
|
||||
conn, err := cm.GetConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return nil, NewConnectionError("create channel", err)
|
||||
}
|
||||
|
||||
cm.channelMutex.Lock()
|
||||
cm.channels = append(cm.channels, ch)
|
||||
cm.channelMutex.Unlock()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (cm *connectionManager) ReturnChannel(ch *amqp.Channel) {
|
||||
if ch == nil || ch.IsClosed() {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case cm.channelPool <- ch:
|
||||
default:
|
||||
ch.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *connectionManager) Close() error {
|
||||
cm.logger.Info().Msg("Closing RabbitMQ connection manager...")
|
||||
|
||||
close(cm.shutdownCh)
|
||||
cm.cancel()
|
||||
|
||||
cm.wg.Wait()
|
||||
|
||||
// Close all channels
|
||||
cm.channelMutex.Lock()
|
||||
for _, ch := range cm.channels {
|
||||
if ch != nil && !ch.IsClosed() {
|
||||
ch.Close()
|
||||
}
|
||||
}
|
||||
cm.channels = nil
|
||||
cm.channelMutex.Unlock()
|
||||
|
||||
// Close channel pool
|
||||
close(cm.channelPool)
|
||||
for ch := range cm.channelPool {
|
||||
if ch != nil && !ch.IsClosed() {
|
||||
ch.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close connection
|
||||
cm.connectionMutex.Lock()
|
||||
defer cm.connectionMutex.Unlock()
|
||||
|
||||
if cm.connection != nil && !cm.connection.IsClosed() {
|
||||
return cm.connection.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm *connectionManager) IsConnected() bool {
|
||||
return atomic.LoadInt32(&cm.isConnected) == 1
|
||||
}
|
||||
|
||||
func (cm *connectionManager) NotifyConnectionLoss() <-chan *amqp.Error {
|
||||
return cm.connectionLossCh
|
||||
}
|
||||
|
||||
func (cm *connectionManager) connect() error {
|
||||
cm.logger.Info().Msg("Connecting to RabbitMQ")
|
||||
|
||||
config := amqp.Config{
|
||||
Heartbeat: cm.config.HeartbeatInterval,
|
||||
Locale: "en_US",
|
||||
}
|
||||
|
||||
if cm.config.ConnectionTimeout > 0 {
|
||||
config.Dial = amqp.DefaultDial(cm.config.ConnectionTimeout)
|
||||
}
|
||||
|
||||
conn, err := amqp.DialConfig(cm.config.BuildConnectionString(), config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
cm.connectionMutex.Lock()
|
||||
cm.connection = conn
|
||||
cm.connectionMutex.Unlock()
|
||||
|
||||
atomic.StoreInt32(&cm.isConnected, 1)
|
||||
cm.reconnectAttempts = 0
|
||||
|
||||
// Setup connection close notification
|
||||
go cm.handleConnectionClose(conn.NotifyClose(make(chan *amqp.Error)))
|
||||
|
||||
cm.logger.Info().Msg("Connected to RabbitMQ")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm *connectionManager) handleConnectionClose(closeCh <-chan *amqp.Error) {
|
||||
select {
|
||||
case err := <-closeCh:
|
||||
if err != nil {
|
||||
cm.logger.Error().Err(err).Msg("Connection lost")
|
||||
atomic.StoreInt32(&cm.isConnected, 0)
|
||||
|
||||
select {
|
||||
case cm.connectionLossCh <- err:
|
||||
default:
|
||||
cm.logger.Error().Err(err).Msg("Connection channel full, dropping notification")
|
||||
}
|
||||
|
||||
// Close all channels
|
||||
cm.channelMutex.Lock()
|
||||
for _, ch := range cm.channels {
|
||||
if ch != nil && !ch.IsClosed() {
|
||||
ch.Close()
|
||||
}
|
||||
}
|
||||
cm.channels = nil
|
||||
cm.channelMutex.Unlock()
|
||||
}
|
||||
case <-cm.shutdownCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *connectionManager) reconnectLoop() {
|
||||
defer cm.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(cm.config.ReconnectDelay)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if !cm.IsConnected() && atomic.CompareAndSwapInt32(&cm.isReconnecting, 0, 1) {
|
||||
cm.attemptReconnect()
|
||||
atomic.StoreInt32(&cm.isReconnecting, 0)
|
||||
}
|
||||
case <-cm.shutdownCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *connectionManager) attemptReconnect() {
|
||||
if cm.config.ReconnectAttempts > 0 && cm.reconnectAttempts >= cm.config.ReconnectAttempts {
|
||||
cm.logger.Error().Msgf("Max reconnect attempts reached: %d", cm.config.ReconnectAttempts)
|
||||
return
|
||||
}
|
||||
|
||||
delay := cm.config.ReconnectDelay
|
||||
if cm.reconnectAttempts > 0 {
|
||||
backoff := time.Duration(cm.reconnectAttempts) * cm.config.ReconnectDelay
|
||||
if backoff > cm.config.MaxReconnectDelay {
|
||||
delay = cm.config.MaxReconnectDelay
|
||||
} else {
|
||||
delay = backoff
|
||||
}
|
||||
}
|
||||
|
||||
if time.Since(cm.lastReconnectTime) < delay {
|
||||
time.Sleep(delay - time.Since(cm.lastReconnectTime))
|
||||
}
|
||||
|
||||
cm.reconnectAttempts++
|
||||
cm.lastReconnectTime = time.Now()
|
||||
|
||||
cm.logger.Info().Msgf("Attempting to reconnect (attempt %d, delay %s)", cm.reconnectAttempts, delay)
|
||||
|
||||
if err := cm.connect(); err != nil {
|
||||
//cm.logger.WithError(err).WithField("attempt", cm.reconnectAttempts).Error("Reconnection failed")
|
||||
cm.logger.Error().Err(err).Msgf("Reconnection failed (attempt %d)", cm.reconnectAttempts)
|
||||
} else {
|
||||
cm.logger.Info().Msgf("Reconnected successfully (attempt %d)", cm.reconnectAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *connectionManager) healthCheckLoop() {
|
||||
defer cm.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(cm.config.HealthCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := cm.healthCheck(); err != nil {
|
||||
cm.logger.Error().Err(err).Msg("Health check failed")
|
||||
atomic.StoreInt32(&cm.isConnected, 0)
|
||||
}
|
||||
case <-cm.shutdownCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *connectionManager) healthCheck() error {
|
||||
conn, err := cm.GetConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if conn.IsClosed() {
|
||||
return ErrConnectionLost
|
||||
}
|
||||
|
||||
// Try to create and close a channel to verify connection health
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return NewConnectionError("health check channel creation", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
200
pkg/rabbit/consumer.go
Normal file
200
pkg/rabbit/consumer.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type consumer struct {
|
||||
connectionManager ConnectionManager
|
||||
handler MessageHandler
|
||||
opts *ConsumerOptions
|
||||
logger zerolog.Logger
|
||||
isConsuming bool
|
||||
consumeMutex sync.RWMutex
|
||||
shutdownCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewConsumer(connectionManager ConnectionManager, handler MessageHandler, opts *ConsumerOptions, logger zerolog.Logger) Consumer {
|
||||
return &consumer{
|
||||
connectionManager: connectionManager,
|
||||
handler: handler,
|
||||
opts: opts,
|
||||
logger: logger,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *consumer) Consume(ctx context.Context) error {
|
||||
c.consumeMutex.Lock()
|
||||
if c.isConsuming {
|
||||
c.consumeMutex.Unlock()
|
||||
return fmt.Errorf("consumer is already consuming")
|
||||
}
|
||||
c.isConsuming = true
|
||||
c.consumeMutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.consumeMutex.Lock()
|
||||
c.isConsuming = false
|
||||
c.consumeMutex.Unlock()
|
||||
}()
|
||||
|
||||
c.logger.Info().Msgf("starting consumer for queue %s", c.opts.Queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.logger.Info().Bool("withErr", ctx.Err() != nil).Msgf("stopping consumer for queue %s", c.opts.Queue)
|
||||
return ctx.Err()
|
||||
case <-c.shutdownCh:
|
||||
c.logger.Info().Msgf("stopping consumer for queue %s with shoutdown", c.opts.Queue)
|
||||
return nil
|
||||
default:
|
||||
if err := c.consumeLoop(ctx, c.opts.Queue, c.handler); err != nil {
|
||||
c.logger.Error().
|
||||
Err(err).
|
||||
Str("errType", fmt.Sprintf("%T", err)).
|
||||
Msgf("error consuming message for queue %s: %s", c.opts.Queue, err)
|
||||
|
||||
// If it's a connection error, wait and retry
|
||||
var connectionError *ConnectionError
|
||||
if errors.As(err, &connectionError) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(c.opts.ReconnectWait):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// if consume error occurred (including delivery channel closed), wait and retry
|
||||
var consumeErr *ConsumeError
|
||||
if errors.As(err, &consumeErr) {
|
||||
c.logger.Warn().Err(errors.Unwrap(consumeErr)).Msg("consume error, will retry")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(c.opts.ReconnectWait):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *consumer) consumeLoop(ctx context.Context, queue string, handler MessageHandler) error {
|
||||
ch, err := c.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewConsumeError(queue, err)
|
||||
}
|
||||
|
||||
if c.opts.PrefetchCount > 0 {
|
||||
err = ch.Qos(
|
||||
c.opts.PrefetchCount,
|
||||
c.opts.PrefetchSize,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
ch.Close()
|
||||
return NewConnectionError("set channel QoS", err)
|
||||
}
|
||||
}
|
||||
|
||||
defer c.connectionManager.ReturnChannel(ch)
|
||||
|
||||
// Start consuming
|
||||
deliveries, err := ch.Consume(
|
||||
queue,
|
||||
c.opts.ConsumerTag,
|
||||
c.opts.AutoAck,
|
||||
c.opts.Exclusive,
|
||||
c.opts.NoLocal,
|
||||
c.opts.NoWait,
|
||||
c.opts.Args,
|
||||
)
|
||||
if err != nil {
|
||||
return NewConsumeError(queue, fmt.Errorf("failed to start consuming: %w", err))
|
||||
}
|
||||
|
||||
c.logger.Info().Msgf("starting consumer for queue %s", queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.shutdownCh:
|
||||
return nil
|
||||
case delivery, ok := <-deliveries:
|
||||
if !ok {
|
||||
c.logger.Warn().Msgf("delivery channel closed for queue %s, will retry", queue)
|
||||
return NewConsumeError(queue, fmt.Errorf("delivery channel closed"))
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go func(d amqp.Delivery) {
|
||||
defer c.wg.Done()
|
||||
c.handleDelivery(ctx, d, handler)
|
||||
}(delivery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *consumer) handleDelivery(ctx context.Context, delivery amqp.Delivery, handler MessageHandler) {
|
||||
msg := c.deliveryToMessage(delivery)
|
||||
|
||||
handler(ctx, msg)
|
||||
}
|
||||
|
||||
func (c *consumer) deliveryToMessage(delivery amqp.Delivery) *Message {
|
||||
headers := make(map[string]interface{})
|
||||
for k, v := range delivery.Headers {
|
||||
headers[k] = v
|
||||
}
|
||||
|
||||
msg := &Message{
|
||||
ID: delivery.MessageId,
|
||||
Body: delivery.Body,
|
||||
ContentType: delivery.ContentType,
|
||||
Headers: headers,
|
||||
Timestamp: delivery.Timestamp,
|
||||
Expiration: delivery.Expiration,
|
||||
Priority: delivery.Priority,
|
||||
DeliveryMode: delivery.DeliveryMode,
|
||||
ReplyTo: delivery.ReplyTo,
|
||||
CorrelationID: delivery.CorrelationId,
|
||||
delivery: &delivery, // Attach delivery for acknowledgment
|
||||
acknowledged: false,
|
||||
}
|
||||
|
||||
// Set ID from headers if not available in MessageId
|
||||
if msg.ID == "" {
|
||||
if id, ok := headers["x-message-id"].(string); ok {
|
||||
msg.ID = id
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func (c *consumer) Close() error {
|
||||
c.logger.Info().Msg("closing consumer")
|
||||
|
||||
// Signal shutdown
|
||||
close(c.shutdownCh)
|
||||
|
||||
// Wait for all message handlers to complete
|
||||
c.wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
105
pkg/rabbit/errors.go
Normal file
105
pkg/rabbit/errors.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConnectionLost = errors.New("rabbitmq connection lost")
|
||||
ErrConnectionFailed = errors.New("failed to connect to rabbitmq")
|
||||
ErrChannelClosed = errors.New("rabbitmq channel closed")
|
||||
ErrInvalidConfig = errors.New("invalid configuration")
|
||||
ErrPublishFailed = errors.New("failed to publish message")
|
||||
ErrConsumeFailed = errors.New("failed to consume message")
|
||||
ErrConfirmationTimeout = errors.New("message confirmation timeout")
|
||||
ErrSerializationFailed = errors.New("message serialization failed")
|
||||
ErrMaxRetriesExceeded = errors.New("maximum retry attempts exceeded")
|
||||
ErrInvalidMessage = errors.New("invalid message format")
|
||||
ErrQueueNotExists = errors.New("queue does not exist")
|
||||
ErrExchangeNotExists = errors.New("exchange does not exist")
|
||||
)
|
||||
|
||||
type ConnectionError struct {
|
||||
Operation string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ConnectionError) Error() string {
|
||||
return fmt.Sprintf("connection error during %s: %v", e.Operation, e.Err)
|
||||
}
|
||||
|
||||
type PublishError struct {
|
||||
Exchange string
|
||||
RoutingKey string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PublishError) Error() string {
|
||||
return fmt.Sprintf("publish error to exchange '%s' with routing key '%s': %v", e.Exchange, e.RoutingKey, e.Err)
|
||||
}
|
||||
|
||||
type ConsumeError struct {
|
||||
Queue string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ConsumeError) Error() string {
|
||||
return fmt.Sprintf("consume error from queue '%s': %v", e.Queue, e.Err)
|
||||
}
|
||||
|
||||
type ConfigurationError struct {
|
||||
Field string
|
||||
Value interface{}
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ConfigurationError) Error() string {
|
||||
return fmt.Sprintf("configuration error: field '%s' with value '%v' - %s", e.Field, e.Value, e.Reason)
|
||||
}
|
||||
|
||||
type RetryError struct {
|
||||
Attempts int
|
||||
LastErr error
|
||||
}
|
||||
|
||||
func (e *RetryError) Error() string {
|
||||
return fmt.Sprintf("retry failed after %d attempts: %v", e.Attempts, e.LastErr)
|
||||
}
|
||||
|
||||
func NewConnectionError(operation string, err error) *ConnectionError {
|
||||
return &ConnectionError{
|
||||
Operation: operation,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPublishError(exchange, routingKey string, err error) *PublishError {
|
||||
return &PublishError{
|
||||
Exchange: exchange,
|
||||
RoutingKey: routingKey,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func NewConsumeError(queue string, err error) *ConsumeError {
|
||||
return &ConsumeError{
|
||||
Queue: queue,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfigurationError(field string, value interface{}, reason string) *ConfigurationError {
|
||||
return &ConfigurationError{
|
||||
Field: field,
|
||||
Value: value,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRetryError(attempts int, lastErr error) *RetryError {
|
||||
return &RetryError{
|
||||
Attempts: attempts,
|
||||
LastErr: lastErr,
|
||||
}
|
||||
}
|
||||
150
pkg/rabbit/message.go
Normal file
150
pkg/rabbit/message.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Body []byte `json:"body"`
|
||||
ContentType string `json:"content_type"`
|
||||
Headers map[string]interface{} `json:"headers"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Expiration string `json:"expiration,omitempty"`
|
||||
Priority uint8 `json:"priority,omitempty"`
|
||||
DeliveryMode uint8 `json:"delivery_mode"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
|
||||
// Internal fields for acknowledgment (not exported in JSON)
|
||||
delivery *amqp.Delivery `json:"-"`
|
||||
acknowledged bool `json:"-"`
|
||||
ackMutex sync.Mutex `json:"-"`
|
||||
}
|
||||
|
||||
func (m *Message) Ack() error {
|
||||
m.ackMutex.Lock()
|
||||
defer m.ackMutex.Unlock()
|
||||
|
||||
if m.delivery == nil {
|
||||
return fmt.Errorf("message delivery is nil - cannot acknowledge")
|
||||
}
|
||||
|
||||
if m.acknowledged {
|
||||
return fmt.Errorf("message already acknowledged")
|
||||
}
|
||||
|
||||
m.acknowledged = true
|
||||
return m.delivery.Ack(false)
|
||||
}
|
||||
|
||||
func (m *Message) AckMultiple() error {
|
||||
m.ackMutex.Lock()
|
||||
defer m.ackMutex.Unlock()
|
||||
|
||||
if m.delivery == nil {
|
||||
return fmt.Errorf("message delivery is nil - cannot acknowledge")
|
||||
}
|
||||
|
||||
if m.acknowledged {
|
||||
return fmt.Errorf("message already acknowledged")
|
||||
}
|
||||
|
||||
m.acknowledged = true
|
||||
return m.delivery.Ack(true)
|
||||
}
|
||||
|
||||
func (m *Message) Nack(requeue bool) error {
|
||||
m.ackMutex.Lock()
|
||||
defer m.ackMutex.Unlock()
|
||||
|
||||
if m.delivery == nil {
|
||||
return fmt.Errorf("message delivery is nil - cannot nack")
|
||||
}
|
||||
|
||||
if m.acknowledged {
|
||||
return fmt.Errorf("message already acknowledged")
|
||||
}
|
||||
|
||||
m.acknowledged = true
|
||||
// Note: When requeue=false, message goes to DLQ and RabbitMQ automatically
|
||||
// tracks retry count via x-death header. No need for custom IncrementRetryCount().
|
||||
return m.delivery.Nack(false, requeue)
|
||||
}
|
||||
|
||||
func (m *Message) NackMultiple(requeue bool) error {
|
||||
m.ackMutex.Lock()
|
||||
defer m.ackMutex.Unlock()
|
||||
|
||||
if m.delivery == nil {
|
||||
return fmt.Errorf("message delivery is nil - cannot nack")
|
||||
}
|
||||
|
||||
if m.acknowledged {
|
||||
return fmt.Errorf("message already acknowledged")
|
||||
}
|
||||
|
||||
m.acknowledged = true
|
||||
return m.delivery.Nack(true, requeue)
|
||||
}
|
||||
|
||||
func (m *Message) Reject(requeue bool) error {
|
||||
m.ackMutex.Lock()
|
||||
defer m.ackMutex.Unlock()
|
||||
|
||||
if m.delivery == nil {
|
||||
return fmt.Errorf("message delivery is nil - cannot reject")
|
||||
}
|
||||
|
||||
if m.acknowledged {
|
||||
return fmt.Errorf("message already acknowledged")
|
||||
}
|
||||
|
||||
m.acknowledged = true
|
||||
return m.delivery.Reject(requeue)
|
||||
}
|
||||
|
||||
func (m *Message) IsAcknowledged() bool {
|
||||
m.ackMutex.Lock()
|
||||
defer m.ackMutex.Unlock()
|
||||
return m.acknowledged
|
||||
}
|
||||
|
||||
func (m *Message) GetRetryCount() int64 {
|
||||
if m.Headers == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if retryCount, ok := m.Headers["x-retry-count"]; ok {
|
||||
switch v := retryCount.(type) {
|
||||
case int:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case string:
|
||||
// Try to parse string as integer
|
||||
if count := parseInt(v); count >= 0 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xDeath, exists := m.Headers["x-death"].([]interface{})
|
||||
if exists {
|
||||
return xDeath[0].(amqp.Table)["count"].(int64)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
func parseInt(s string) int64 {
|
||||
var count int64
|
||||
_, err := fmt.Sscanf(s, "%d", &count)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return count
|
||||
}
|
||||
223
pkg/rabbit/publisher.go
Normal file
223
pkg/rabbit/publisher.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"base/pkg/metrics"
|
||||
)
|
||||
|
||||
type publisher struct {
|
||||
connectionManager ConnectionManager
|
||||
config *Config
|
||||
logger zerolog.Logger
|
||||
confirmChannels map[uint64]chan amqp.Confirmation
|
||||
confirmMutex sync.RWMutex
|
||||
nextConfirmID uint64
|
||||
confirmMux sync.Mutex
|
||||
metric *metrics.Metrics
|
||||
}
|
||||
|
||||
func NewPublisher(
|
||||
connectionManager ConnectionManager,
|
||||
config *Config,
|
||||
logger zerolog.Logger,
|
||||
metric *metrics.Metrics,
|
||||
) Publisher {
|
||||
return &publisher{
|
||||
connectionManager: connectionManager,
|
||||
config: config,
|
||||
logger: logger,
|
||||
confirmChannels: make(map[uint64]chan amqp.Confirmation),
|
||||
metric: metric,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *publisher) Publish(ctx context.Context, exchange, routingKey string, msg *Message) error {
|
||||
start := time.Now()
|
||||
pubErr := p.publishWithRetry(ctx, exchange, routingKey, msg, false)
|
||||
duration := time.Since(start)
|
||||
|
||||
p.metric.RecordRabbitMQMessage(exchange, routingKey, "publish", duration, pubErr)
|
||||
|
||||
return pubErr
|
||||
}
|
||||
|
||||
func (p *publisher) publishWithRetry(ctx context.Context, exchange, routingKey string, msg *Message, withConfirmation bool) error {
|
||||
var lastErr error
|
||||
|
||||
if msg == nil {
|
||||
return ErrInvalidMessage
|
||||
}
|
||||
|
||||
if msg.ID == "" {
|
||||
msg.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
if msg.Timestamp.IsZero() {
|
||||
msg.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
if msg.DeliveryMode == 0 {
|
||||
msg.DeliveryMode = DeliveryModePersistent
|
||||
}
|
||||
|
||||
maxAttempts := p.config.PublisherConfig.RetryAttempts + 1
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(p.config.PublisherConfig.RetryDelay):
|
||||
}
|
||||
}
|
||||
|
||||
err := p.doPublish(ctx, exchange, routingKey, msg, withConfirmation)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if !p.isRetryableError(err) {
|
||||
break
|
||||
}
|
||||
|
||||
p.logger.Warn().Str("exchange", exchange).
|
||||
Str("routing_key", routingKey).
|
||||
Str("message_id", msg.ID).
|
||||
Int("attempt", attempt+1).
|
||||
Int("max_attempts", maxAttempts).
|
||||
Err(err).
|
||||
Msg("Retrying message publish")
|
||||
}
|
||||
|
||||
return NewRetryError(maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func (p *publisher) doPublish(ctx context.Context, exchange, routingKey string, msg *Message, withConfirmation bool) error {
|
||||
ch, err := p.connectionManager.GetChannel()
|
||||
if err != nil {
|
||||
return NewPublishError(exchange, routingKey, err)
|
||||
}
|
||||
defer p.connectionManager.ReturnChannel(ch)
|
||||
|
||||
// Convert message to AMQP publishing
|
||||
publishing, err := p.messageToPublishing(msg)
|
||||
if err != nil {
|
||||
return NewPublishError(exchange, routingKey, fmt.Errorf("failed publish in convert message: %w", err))
|
||||
}
|
||||
|
||||
// Publish the message
|
||||
err = ch.PublishWithContext(
|
||||
ctx,
|
||||
exchange,
|
||||
routingKey,
|
||||
p.config.PublisherConfig.Mandatory,
|
||||
p.config.PublisherConfig.Immediate,
|
||||
*publishing,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return NewPublishError(exchange, routingKey, fmt.Errorf("failed to publish: %w", err))
|
||||
}
|
||||
|
||||
p.logger.Info().
|
||||
Str("exchange", exchange).
|
||||
Str("payload", string(msg.Body)).
|
||||
Str("correlationID", msg.CorrelationID).
|
||||
Str("routing_key", routingKey).Msg("MessagePublished")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *publisher) messageToPublishing(msg *Message) (*amqp.Publishing, error) {
|
||||
headers := make(amqp.Table)
|
||||
for k, v := range msg.Headers {
|
||||
headers[k] = v
|
||||
}
|
||||
|
||||
// Add metadata headers
|
||||
headers["x-message-id"] = msg.ID
|
||||
headers["x-published-at"] = msg.Timestamp.Format(time.RFC3339)
|
||||
|
||||
publishing := &amqp.Publishing{
|
||||
Headers: headers,
|
||||
ContentType: msg.ContentType,
|
||||
Body: msg.Body,
|
||||
DeliveryMode: msg.DeliveryMode,
|
||||
Priority: msg.Priority,
|
||||
Timestamp: msg.Timestamp,
|
||||
MessageId: msg.ID,
|
||||
ReplyTo: msg.ReplyTo,
|
||||
CorrelationId: msg.CorrelationID,
|
||||
}
|
||||
|
||||
if msg.Expiration != "" {
|
||||
publishing.Expiration = msg.Expiration
|
||||
}
|
||||
|
||||
return publishing, nil
|
||||
}
|
||||
|
||||
func (p *publisher) isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific error types that should not be retried
|
||||
switch err {
|
||||
case ErrInvalidMessage:
|
||||
return false
|
||||
case ErrInvalidConfig:
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for AMQP errors
|
||||
if amqpErr, ok := err.(*amqp.Error); ok {
|
||||
switch amqpErr.Code {
|
||||
case amqp.NotFound: // 404 - Queue/Exchange not found
|
||||
return false
|
||||
case amqp.AccessRefused: // 403 - Access refused
|
||||
return false
|
||||
case amqp.InvalidPath: // 402 - Invalid path
|
||||
return false
|
||||
case amqp.ResourceLocked: // 405 - Resource locked
|
||||
return false
|
||||
case amqp.PreconditionFailed: // 406 - Precondition failed
|
||||
return false
|
||||
case amqp.NotImplemented: // 540 - Not implemented
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for connection errors (these are usually retryable)
|
||||
if _, ok := err.(*ConnectionError); ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *publisher) Close() error {
|
||||
p.logger.Info().Msg("Closing publisher")
|
||||
|
||||
// Close all confirmation channels
|
||||
p.confirmMutex.Lock()
|
||||
for _, ch := range p.confirmChannels {
|
||||
close(ch)
|
||||
}
|
||||
p.confirmChannels = make(map[uint64]chan amqp.Confirmation)
|
||||
p.confirmMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
103
pkg/rabbit/rabbitmq.go
Normal file
103
pkg/rabbit/rabbitmq.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const Version = "1.0.0"
|
||||
|
||||
type Publisher interface {
|
||||
Publish(ctx context.Context, exchange, routingKey string, msg *Message) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Consumer interface {
|
||||
Consume(ctx context.Context) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type MessageHandler func(ctx context.Context, msg *Message)
|
||||
|
||||
type Client interface {
|
||||
Publisher() Publisher
|
||||
RegisterConsumer(handler MessageHandler, opts *ConsumerOptions) Consumer
|
||||
DeclareQueue(name string, opts QueueOptions) error
|
||||
DeclareExchange(name string, opts ExchangeOptions) error
|
||||
BindQueue(queue, exchange, routingKey string) error
|
||||
DeleteQueue(name string) error
|
||||
DeleteExchange(name string) error
|
||||
HealthCheck() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type ConnectionManager interface {
|
||||
GetConnection() (*amqp.Connection, error)
|
||||
GetChannel() (*amqp.Channel, error)
|
||||
ReturnChannel(*amqp.Channel)
|
||||
Close() error
|
||||
IsConnected() bool
|
||||
NotifyConnectionLoss() <-chan *amqp.Error
|
||||
}
|
||||
|
||||
type QueueOptions struct {
|
||||
Durable bool
|
||||
AutoDelete bool
|
||||
Exclusive bool
|
||||
NoWait bool
|
||||
Args amqp.Table
|
||||
}
|
||||
|
||||
type ExchangeOptions struct {
|
||||
Type string
|
||||
Durable bool
|
||||
AutoDelete bool
|
||||
Internal bool
|
||||
NoWait bool
|
||||
Args amqp.Table
|
||||
}
|
||||
|
||||
type ConsumerOptions struct {
|
||||
Queue string
|
||||
ConsumerTag string
|
||||
AutoAck bool
|
||||
Exclusive bool
|
||||
NoLocal bool
|
||||
NoWait bool
|
||||
PrefetchCount int
|
||||
PrefetchSize int
|
||||
Args amqp.Table
|
||||
ReconnectWait time.Duration
|
||||
}
|
||||
|
||||
type PublisherOptions struct {
|
||||
ConfirmMode bool
|
||||
Mandatory bool
|
||||
Immediate bool
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
ConfirmTimeout time.Duration
|
||||
Args amqp.Table
|
||||
}
|
||||
|
||||
const (
|
||||
ExchangeTypeDirect = "direct"
|
||||
ExchangeTypeFanout = "fanout"
|
||||
ExchangeTypeTopic = "topic"
|
||||
ExchangeTypeHeaders = "headers"
|
||||
)
|
||||
|
||||
const (
|
||||
DeliveryModeTransient = 1
|
||||
DeliveryModePersistent = 2
|
||||
)
|
||||
|
||||
const (
|
||||
PriorityLowest = 0
|
||||
PriorityLow = 64
|
||||
PriorityNormal = 128
|
||||
PriorityHigh = 192
|
||||
PriorityHighest = 255
|
||||
)
|
||||
Reference in New Issue
Block a user