247 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|