platform
This commit is contained in:
1 parent
e706f7d907
commit
21bcb63440
42 files changed
+5903
No files matched your search
+265
@@ -0,0 +1,265 @@
|
||||
// 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 Win 3.
|
||||
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{}),
|
||||
}
|
||||
raw, err := dial(url, name)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package rabbit_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
)
|
||||
|
||||
// rabbitURL returns the RabbitMQ URL for integration tests, or empty if unset.
|
||||
// Tests check this and skip when no broker is available.
|
||||
func rabbitURL() string {
|
||||
if u := os.Getenv("RABBIT_URL"); u != "" {
|
||||
return u
|
||||
}
|
||||
if u := os.Getenv("RABBIT_HOST"); u != "" {
|
||||
return "amqp://" + u
|
||||
}
|
||||
// Default local dev URL used by docker-compose.
|
||||
return "amqp://guest:guest@localhost:5672"
|
||||
}
|
||||
|
||||
// skipWithoutRabbit skips the test if no RabbitMQ broker is reachable.
|
||||
func skipWithoutRabbit(t *testing.T) string {
|
||||
t.Helper()
|
||||
url := rabbitURL()
|
||||
// Quick probe: try connecting and immediately close.
|
||||
conn, err := rabbit.Connect(url, "test-probe")
|
||||
if err != nil {
|
||||
t.Skipf("RabbitMQ not available at %s: %v", url, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return url
|
||||
}
|
||||
|
||||
func TestDial_ConnectAndClose(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-conn")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// The underlying connection should be usable.
|
||||
if c := conn.Connection(); c == nil {
|
||||
t.Fatal("Connection() returned nil")
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
t.Fatalf("Channel() failed: %v", err)
|
||||
}
|
||||
if err := ch.Close(); err != nil {
|
||||
t.Fatalf("channel close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_Done(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-done")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
|
||||
// Done should NOT be closed initially.
|
||||
select {
|
||||
case <-conn.Done():
|
||||
t.Fatal("Done channel closed before Close()")
|
||||
default:
|
||||
}
|
||||
|
||||
// After Close, Done should be closed.
|
||||
_ = conn.Close()
|
||||
select {
|
||||
case <-conn.Done():
|
||||
// expected
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Done channel not closed after Close()")
|
||||
}
|
||||
|
||||
// Double close is safe.
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestDial_NotifyOnReconnect(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-notify")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// NotifyOnReconnect should call the function immediately.
|
||||
called := make(chan struct{}, 1)
|
||||
conn.NotifyOnReconnect(func() {
|
||||
called <- struct{}{}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
// expected — immediate call
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("NotifyOnReconnect was not called immediately")
|
||||
}
|
||||
|
||||
// Multiple registrations should each fire immediately.
|
||||
var mu sync.Mutex
|
||||
count := 0
|
||||
conn.NotifyOnReconnect(func() {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
})
|
||||
conn.NotifyOnReconnect(func() {
|
||||
mu.Lock()
|
||||
count++
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// Wait a moment for the callbacks to run.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
mu.Lock()
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 immediate NotifyOnReconnect calls, got %d", count)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestDial_NotifyReconnect_NotImmediate(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-notify-reconnect")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
called := make(chan struct{}, 1)
|
||||
conn.NotifyReconnect(func() {
|
||||
called <- struct{}{}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("NotifyReconnect fired immediately")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_OnReconnectChannel(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-onreconn")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// OnReconnect returns an open channel immediately after Dial.
|
||||
// It should NOT fire (only triggers on actual reconnects).
|
||||
select {
|
||||
case <-conn.OnReconnect():
|
||||
t.Log("OnReconnect fired immediately (race with initial connect — allowed)")
|
||||
default:
|
||||
}
|
||||
|
||||
// Verify OnReconnect returns a non-nil channel.
|
||||
ch := conn.OnReconnect()
|
||||
if ch == nil {
|
||||
t.Fatal("OnReconnect returned nil channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_ConcurrentUse(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-concurrent")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Channel() and Connection() should be safe from multiple goroutines.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
t.Logf("concurrent Channel(): %v", err)
|
||||
return
|
||||
}
|
||||
_ = ch.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestOnReconnect_RegisteredFnsFiresOnReconnect is a structural test: it
|
||||
// verifies that the registered notification list is called on reconnect by
|
||||
// checking that functions registered after initial connect are not called
|
||||
// immediately (they only fire on the NEXT reconnect). Full reconnect testing
|
||||
// requires controlling the broker — see the integration test harness.
|
||||
func TestOnReconnect_NotImmediate(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
conn, err := rabbit.Dial(url, "test-reconn-struct")
|
||||
if err != nil {
|
||||
t.Fatalf("Dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Register a function with NotifyOnReconnect AFTER Dial. It fires
|
||||
// immediately (that's the contract).
|
||||
immediate := make(chan struct{}, 1)
|
||||
conn.NotifyOnReconnect(func() {
|
||||
immediate <- struct{}{}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-immediate:
|
||||
// expected
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("NotifyOnReconnect did not fire immediately")
|
||||
}
|
||||
|
||||
// OnReconnect should NOT have fired (only fires on actual reconnect).
|
||||
select {
|
||||
case <-conn.OnReconnect():
|
||||
t.Log("OnReconnect fired — race goroutine from initial connect")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_BackwardCompat(t *testing.T) {
|
||||
url := skipWithoutRabbit(t)
|
||||
|
||||
// Connect should still work and return a usable *amqp.Connection.
|
||||
conn, err := rabbit.Connect(url, "test-compat")
|
||||
if err != nil {
|
||||
t.Fatalf("Connect failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
t.Fatalf("Channel from Connect failed: %v", err)
|
||||
}
|
||||
_ = ch.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package rabbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/event"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// PublishEvent marshals an event.Event and publishes it to exchange with
|
||||
// routingKey = string(ev.Type). Opens and closes a channel per call. The
|
||||
// exchange must already exist (declare it with ListenToPattern or call
|
||||
// ExchangeDeclare explicitly before the first publish).
|
||||
func PublishEvent(c *amqp.Connection, exchange string, ev event.Event) error {
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch, err := c.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
return ch.Publish(exchange, string(ev.Type), true, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListenToPattern binds an exclusive queue with a routing-key pattern (wildcard)
|
||||
// on a durable topic exchange, so one subscription receives every message
|
||||
// matching the pattern. A handler error nacks (drops) that single message and
|
||||
// keeps consuming.
|
||||
func ListenToPattern(ch *amqp.Channel, exchange, pattern string, handler func(amqp.Delivery) error) error {
|
||||
if err := ch.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
q, err := ch.QueueDeclare("", false, false, true, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ch.QueueBind(q.Name, pattern, exchange, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
msgs, err := ch.Consume(q.Name, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
for d := range msgs {
|
||||
if err := handler(d); err != nil {
|
||||
log.Printf("Error processing pattern message: %v", err)
|
||||
d.Nack(false, false)
|
||||
} else {
|
||||
d.Ack(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user