276 lines
7.7 KiB
Go
276 lines
7.7 KiB
Go
// Package rabbit holds the shared RabbitMQ transport mechanics — connection
|
|
// setup plus topic declare/publish/consume helpers — that every module used to
|
|
// hand-roll. It carries NO event vocabulary: topic and routing names are plain
|
|
// strings supplied by the caller, so each module keeps its own naming policy
|
|
// (finder's "<country>_<topic>", CMS's "kind.dataType", etc.). See
|
|
// docs/platform-uniforming-plan.md (platform/rabbit seam, migration done).
|
|
package rabbit
|
|
|
|
import (
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
amqp "github.com/rabbitmq/amqp091-go"
|
|
)
|
|
|
|
// Default backoff bounds for the automatic reconnect loop.
|
|
const (
|
|
backoffMin = 1 * time.Second
|
|
backoffMax = 30 * time.Second
|
|
)
|
|
|
|
// Connect dials url with a consistent configuration, tagging the connection with
|
|
// name so it is identifiable in the RabbitMQ management UI and metrics. It
|
|
// returns a bare *amqp.Connection and does NOT reconnect on failure.
|
|
//
|
|
// Prefer Dial instead: it returns a *Conn wrapper that handles automatic
|
|
// reconnection and notifies consumers when a reconnect completes so they can
|
|
// re-declare their topology.
|
|
//
|
|
// This replaces the 14 inconsistent hand-rolled dial sites (some with
|
|
// connection Properties, some bare amqp.Dial with no name).
|
|
func Connect(url, name string) (*amqp.Connection, error) {
|
|
return dial(url, name)
|
|
}
|
|
|
|
// dial is the shared dial implementation used by both Connect and Dial.
|
|
func dial(url, name string) (*amqp.Connection, error) {
|
|
props := amqp.NewConnectionProperties()
|
|
if name != "" {
|
|
props["connection_name"] = name
|
|
}
|
|
return amqp.DialConfig(url, amqp.Config{Properties: props})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Conn wraps *amqp.Connection with automatic reconnection. After Dial, it
|
|
// monitors NotifyClose and re-dials with exponential backoff (1s→30s). All
|
|
// exported methods are safe for concurrent use.
|
|
//
|
|
// Usage:
|
|
//
|
|
// conn, err := rabbit.Dial(url, "my-service")
|
|
// if err != nil { return err }
|
|
// defer conn.Close()
|
|
//
|
|
// // Register consumer setup: called immediately AND on each reconnect.
|
|
// conn.NotifyOnReconnect(func() {
|
|
// ch, err := conn.Channel()
|
|
// if err != nil { return }
|
|
// rabbit.ListenToTopic(ch, prefix, topic, handler)
|
|
// })
|
|
type Conn struct {
|
|
url string
|
|
name string
|
|
|
|
mu sync.Mutex
|
|
conn *amqp.Connection
|
|
done chan struct{} // closed by Close(); stops the reconnect loop
|
|
|
|
onReconn chan struct{} // closed on each reconnect to wake up listeners
|
|
|
|
notifyFns []func() // registered via NotifyOnReconnect
|
|
reconnectFns []func() // registered via NotifyReconnect
|
|
}
|
|
|
|
// Dial connects to RabbitMQ and returns a *Conn that automatically reconnects
|
|
// on connection loss. The name is shown in the RabbitMQ management UI.
|
|
func Dial(url, name string) (*Conn, error) {
|
|
c := &Conn{
|
|
url: url,
|
|
name: name,
|
|
done: make(chan struct{}),
|
|
onReconn: make(chan struct{}),
|
|
}
|
|
var raw *amqp.Connection
|
|
var err error
|
|
for attempts := 0; attempts < 10; attempts++ {
|
|
raw, err = dial(url, name)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if attempts < 9 {
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.conn = raw
|
|
|
|
// Fire initial connect signal so a consumer registered before this runs
|
|
// (e.g. via NotifyOnReconnect above) gets called. Since onReconn starts
|
|
// open, we close it here and replace it with a fresh one for reconnect.
|
|
close(c.onReconn)
|
|
c.onReconn = make(chan struct{})
|
|
|
|
go c.reconnectLoop()
|
|
return c, nil
|
|
}
|
|
|
|
// Connection returns the current *amqp.Connection. Safe for concurrent use.
|
|
// Use this to pass the underlying connection to functions that expect
|
|
// *amqp.Connection, such as SendChange and the finder's messaging wrappers.
|
|
func (c *Conn) Connection() *amqp.Connection {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.conn
|
|
}
|
|
|
|
// Channel opens a channel on the current underlying connection. It is safe
|
|
// for concurrent use; the error path matches *amqp.Connection.Channel.
|
|
func (c *Conn) Channel() (*amqp.Channel, error) {
|
|
return c.Connection().Channel()
|
|
}
|
|
|
|
// OnReconnect returns a channel that is closed each time a reconnect completes.
|
|
// Use range to re-run consumer setup:
|
|
//
|
|
// go func() {
|
|
// for range conn.OnReconnect() {
|
|
// ch, _ := conn.Channel()
|
|
// rabbit.ListenToTopic(ch, prefix, topic, handler)
|
|
// }
|
|
// }()
|
|
//
|
|
// The channel is NOT signalled on initial connect — only on reconnects.
|
|
// For a function that fires on both initial connect and reconnect, use
|
|
// NotifyOnReconnect.
|
|
func (c *Conn) OnReconnect() <-chan struct{} {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.onReconn
|
|
}
|
|
|
|
// NotifyOnReconnect registers a function that is called immediately (on the
|
|
// current goroutine) and again on each subsequent reconnect (on the reconnect
|
|
// loop goroutine). The function should be idempotent — it may be called while
|
|
// the old listeners are still shutting down from the previous connection drop.
|
|
// Multiple functions can be registered and are called in registration order.
|
|
func (c *Conn) NotifyOnReconnect(fn func()) {
|
|
// Call immediately with the current (live) connection.
|
|
fn()
|
|
|
|
c.mu.Lock()
|
|
c.notifyFns = append(c.notifyFns, fn)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// NotifyReconnect registers a function that is called on each subsequent
|
|
// reconnect, but NOT immediately on registration. Use this when the caller
|
|
// needs to perform first-time setup separately and only wants the reconnect
|
|
// hook for re-declaring consumers or topology.
|
|
func (c *Conn) NotifyReconnect(fn func()) {
|
|
c.mu.Lock()
|
|
c.reconnectFns = append(c.reconnectFns, fn)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Done returns a channel that is closed when the connection is permanently
|
|
// closed (Close was called).
|
|
func (c *Conn) Done() <-chan struct{} {
|
|
return c.done
|
|
}
|
|
|
|
// Close permanently closes the connection and stops the reconnect loop.
|
|
// It is safe to call multiple times.
|
|
func (c *Conn) Close() error {
|
|
select {
|
|
case <-c.done:
|
|
return nil
|
|
default:
|
|
}
|
|
close(c.done)
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.conn != nil {
|
|
return c.conn.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconnectLoop runs in a background goroutine. It waits for the connection
|
|
// to close (either normally or with an error), then re-dials with exponential
|
|
// backoff and notifies registered callbacks.
|
|
func (c *Conn) reconnectLoop() {
|
|
for {
|
|
// Wait for the connection to break (or Close to be called).
|
|
errCh := make(chan *amqp.Error)
|
|
c.mu.Lock()
|
|
conn := c.conn
|
|
c.mu.Unlock()
|
|
|
|
conn.NotifyClose(errCh)
|
|
|
|
select {
|
|
case <-c.done:
|
|
return
|
|
case err, ok := <-errCh:
|
|
if !ok || err == nil {
|
|
// Channel closed or clean shutdown (Close called on the underlying
|
|
// connection). Either way, stop reconnecting.
|
|
return
|
|
}
|
|
log.Printf("rabbit: connection %s lost: %v (reconnecting)", c.name, err)
|
|
}
|
|
|
|
// Reconnect with exponential backoff.
|
|
c.reconnect()
|
|
}
|
|
}
|
|
|
|
// reconnect attempts to re-dial until successful or Close is called.
|
|
func (c *Conn) reconnect() {
|
|
duration := backoffMin
|
|
for {
|
|
select {
|
|
case <-c.done:
|
|
return
|
|
default:
|
|
}
|
|
|
|
newConn, err := dial(c.url, c.name)
|
|
if err == nil {
|
|
c.mu.Lock()
|
|
old := c.conn
|
|
c.conn = newConn
|
|
c.mu.Unlock()
|
|
if old != nil {
|
|
_ = old.Close()
|
|
}
|
|
|
|
log.Printf("rabbit: connection %s re-established", c.name)
|
|
|
|
// Notify consumers: close the current signal channel so blocked
|
|
// OnReconnect calls advance; replace with a fresh one.
|
|
c.mu.Lock()
|
|
close(c.onReconn)
|
|
c.onReconn = make(chan struct{})
|
|
fns := append([]func(){}, c.notifyFns...)
|
|
fns = append(fns, c.reconnectFns...)
|
|
c.mu.Unlock()
|
|
|
|
for _, fn := range fns {
|
|
fn()
|
|
}
|
|
return
|
|
}
|
|
|
|
log.Printf("rabbit: reconnect for %s failed: %v (retry in %v)", c.name, err, duration)
|
|
|
|
select {
|
|
case <-c.done:
|
|
return
|
|
case <-time.After(duration):
|
|
}
|
|
|
|
duration *= 2
|
|
if duration > backoffMax {
|
|
duration = backoffMax
|
|
}
|
|
}
|
|
}
|