Files
go-sip/internal/mq/amqp.go
T

247 lines
6.9 KiB
Go

// Package mq contains the RabbitMQ adapter. Business state remains in the
// Dispatcher store; this package only declares topology and transports bytes.
package mq
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"git.ipao.vip/rogee/go-sip/internal/tenant"
amqp "github.com/rabbitmq/amqp091-go"
)
var consumerSequence atomic.Uint64
const (
DefaultExchange = tenant.CommandExchange
EventExchange = tenant.EventExchange
DeadLetterExchange = "agent-call.dead-letter.v1"
DefaultPrefetch = 1
)
type Publisher interface {
Publish(context.Context, string, string, []byte) error
}
type permanentError struct{ err error }
func (e permanentError) Error() string { return e.err.Error() }
func (e permanentError) Unwrap() error { return e.err }
func Permanent(err error) error {
if err == nil {
return nil
}
return permanentError{err: err}
}
func IsPermanent(err error) bool {
var target permanentError
return errors.As(err, &target)
}
type Broker struct {
conn *amqp.Connection
channel *amqp.Channel
exchange string
eventExchange string
prefetch int
mu sync.Mutex
}
func Open(url, exchange string) (*Broker, error) {
return OpenWithPrefetch(url, exchange, DefaultPrefetch)
}
func OpenWithPrefetch(url, exchange string, prefetch int) (*Broker, error) {
if url == "" {
return nil, errors.New("rabbitmq URL is required")
}
if prefetch <= 0 {
return nil, errors.New("prefetch must be positive")
}
if exchange == "" {
exchange = DefaultExchange
}
if exchange != DefaultExchange {
return nil, fmt.Errorf("unsupported command exchange %q", exchange)
}
conn, err := amqp.Dial(url)
if err != nil {
return nil, fmt.Errorf("dial rabbitmq: %w", err)
}
channel, err := conn.Channel()
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("open rabbitmq channel: %w", err)
}
if err := channel.ExchangeDeclare(exchange, "direct", true, false, false, false, nil); err != nil {
_ = channel.Close()
_ = conn.Close()
return nil, fmt.Errorf("declare command exchange: %w", err)
}
if err := channel.ExchangeDeclare(EventExchange, "topic", true, false, false, false, nil); err != nil {
_ = channel.Close()
_ = conn.Close()
return nil, fmt.Errorf("declare event exchange: %w", err)
}
if err := channel.Confirm(false); err != nil {
_ = channel.Close()
_ = conn.Close()
return nil, fmt.Errorf("enable publisher confirms: %w", err)
}
if err := channel.ExchangeDeclare(DeadLetterExchange, "topic", true, false, false, false, nil); err != nil {
_ = channel.Close()
_ = conn.Close()
return nil, fmt.Errorf("declare dead-letter exchange: %w", err)
}
return &Broker{conn: conn, channel: channel, exchange: exchange, eventExchange: EventExchange, prefetch: prefetch}, nil
}
func (b *Broker) Close() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.channel != nil {
_ = b.channel.Close()
}
if b.conn != nil {
return b.conn.Close()
}
return nil
}
func (b *Broker) Publish(ctx context.Context, exchange, routingKey string, body []byte) error {
if exchange == "" || routingKey == "" || len(body) == 0 {
return errors.New("routing key and body are required")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.channel == nil {
return errors.New("rabbitmq channel is closed")
}
if exchange != b.exchange && exchange != b.eventExchange {
return fmt.Errorf("unsupported publish exchange %q", exchange)
}
confirmation, err := b.channel.PublishWithDeferredConfirmWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Body: body,
})
if err != nil {
return err
}
if confirmation == nil {
return errors.New("rabbitmq publisher confirmation unavailable")
}
acked, err := confirmation.WaitContext(ctx)
if err != nil {
return err
}
if !acked {
return errors.New("rabbitmq publisher was negatively acknowledged")
}
return nil
}
func (b *Broker) DeclareTenantQueue(tenantKey string) (string, error) {
queue, err := tenant.CommandQueue(tenantKey)
if err != nil {
return "", err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.channel == nil {
return "", errors.New("rabbitmq channel is closed")
}
routingKey, err := tenant.CommandRoutingKey(tenantKey)
if err != nil {
return "", err
}
deadLetterQueue, err := tenant.DeadLetterQueue(tenantKey)
if err != nil {
return "", err
}
queueArgs := amqp.Table{
"x-dead-letter-exchange": DeadLetterExchange,
"x-dead-letter-routing-key": routingKey,
}
if _, err := b.channel.QueueDeclare(queue, true, false, false, false, queueArgs); err != nil {
return "", fmt.Errorf("declare tenant queue: %w", err)
}
if _, err := b.channel.QueueDeclare(deadLetterQueue, true, false, false, false, nil); err != nil {
return "", fmt.Errorf("declare tenant dead-letter queue: %w", err)
}
if err := b.channel.QueueBind(queue, routingKey, b.exchange, false, nil); err != nil {
return "", fmt.Errorf("bind tenant queue: %w", err)
}
if err := b.channel.QueueBind(deadLetterQueue, routingKey, DeadLetterExchange, false, nil); err != nil {
return "", fmt.Errorf("bind tenant dead-letter queue: %w", err)
}
return queue, nil
}
type MessageHandler func(context.Context, string, []byte) error
// Consume ACKs only after the handler returns nil. A transient handler error
// requeues; malformed or unauthorized messages can be rejected by the caller
// with Permanent, which RabbitMQ dead-letters through the tenant queue policy.
func (b *Broker) Consume(ctx context.Context, queue string, handler MessageHandler) error {
if queue == "" || handler == nil {
return errors.New("queue and handler are required")
}
b.mu.Lock()
if b.channel == nil {
b.mu.Unlock()
return errors.New("rabbitmq channel is closed")
}
prefetch := b.prefetch
if prefetch <= 0 {
prefetch = DefaultPrefetch
}
if err := b.channel.Qos(prefetch, 0, false); err != nil {
b.mu.Unlock()
return fmt.Errorf("set tenant prefetch: %w", err)
}
consumerTag := fmt.Sprintf("sip-go-agent-%d", consumerSequence.Add(1))
deliveries, err := b.channel.Consume(queue, consumerTag, false, false, false, false, nil)
b.mu.Unlock()
if err != nil {
return fmt.Errorf("consume tenant queue: %w", err)
}
defer func() {
b.mu.Lock()
if b.channel != nil {
_ = b.channel.Cancel(consumerTag, false)
}
b.mu.Unlock()
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return errors.New("rabbitmq delivery channel closed")
}
if err := handler(ctx, d.RoutingKey, d.Body); err != nil {
if IsPermanent(err) {
if rejectErr := d.Reject(false); rejectErr != nil {
return fmt.Errorf("permanent handler error %v; reject: %w", err, rejectErr)
}
continue
}
if nackErr := d.Nack(false, true); nackErr != nil {
return fmt.Errorf("handler error %v; nack: %w", err, nackErr)
}
continue
}
if err := d.Ack(false); err != nil {
return fmt.Errorf("ack delivery: %w", err)
}
}
}
}