This commit is contained in:
mats committed 2026-06-28 17:52:49 +02:00
1 parent e706f7d907
commit 21bcb63440
42 files changed
+5903

No files matched your search

+265
View File
@@ -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
}
}
}
+258
View File
@@ -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()
}
+64
View File
@@ -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
}