initial commit

This commit is contained in:
m.zare
2026-04-10 18:25:21 +03:30
commit 77ca6c34a3
263 changed files with 34470 additions and 0 deletions

227
pkg/rabbit/client.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
)