Files
enkly-ai/pkg/eventbus/bus.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

160 lines
5.6 KiB
Go

package eventbus
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// Bus is a small, reconnect-free wrapper over one amqp091 connection and
// channel. It declares ExchangeName as a durable topic exchange on
// connect so publishers and consumers never race over who creates it
// first.
//
// Deliberately minimal: no automatic reconnect/retry loop, no publisher
// confirms, no dead-letter exchange. Those are real production
// concerns, but adding them here would be infrastructure plumbing
// unrelated to the DDD restructuring this package exists to
// demonstrate — see the "Operational hardening" section of
// ARCHITECTURE.md for what a production rollout should add before this
// goes live.
type Bus struct {
conn *amqp.Connection
channel *amqp.Channel
logger *slog.Logger
}
// Connect dials url (an amqp:// URI) and declares the shared topology.
func Connect(url string, logger *slog.Logger) (*Bus, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, fmt.Errorf("dial rabbitmq: %w", err)
}
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("open channel: %w", err)
}
if err := ch.ExchangeDeclare(ExchangeName, "topic", true, false, false, false, nil); err != nil {
_ = ch.Close()
_ = conn.Close()
return nil, fmt.Errorf("declare exchange: %w", err)
}
return &Bus{conn: conn, channel: ch, logger: logger}, nil
}
// Close releases the underlying channel and connection.
func (b *Bus) Close() error {
if b == nil {
return nil
}
if err := b.channel.Close(); err != nil {
_ = b.conn.Close()
return err
}
return b.conn.Close()
}
// Envelope is the JSON structure every event is published as. OccurredAt
// and RoutingKey are set by Publish; Payload is whatever event-specific
// struct the caller passes (see pkg/eventbus/events).
type Envelope struct {
RoutingKey string `json:"routing_key"`
OccurredAt time.Time `json:"occurred_at"`
Payload json.RawMessage `json:"payload"`
}
// Publish marshals payload and sends it to ExchangeName with the given
// routing key, as a persistent message (survives a broker restart).
// Fire-and-forget: the caller's own request/response flow (over gRPC)
// must never block on this succeeding — a publish failure is logged,
// not propagated, exactly like the monolith's own "log, don't fail the
// request" stance on e.g. a cleanup-loop error.
func (b *Bus) Publish(ctx context.Context, routingKey string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal event payload: %w", err)
}
env := Envelope{RoutingKey: routingKey, OccurredAt: time.Now().UTC(), Payload: body}
envBody, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("marshal envelope: %w", err)
}
return b.channel.PublishWithContext(ctx, ExchangeName, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: envBody,
})
}
// PublishBestEffort calls Publish and logs (rather than returns) any
// error — the shape every call site in this codebase actually wants,
// since a notification being late or lost must never fail the
// synchronous gRPC call that triggered it.
func (b *Bus) PublishBestEffort(ctx context.Context, routingKey string, payload any) {
if err := b.Publish(ctx, routingKey, payload); err != nil {
b.logger.Error("publish event", "routing_key", routingKey, "err", err)
}
}
// Handler processes one decoded event. Returning an error nacks the
// delivery with requeue=true; a consumer that can't make progress
// should log and return nil instead of spinning forever on one poison
// message (the notification service does exactly this — see
// services/notification/internal/infrastructure/eventing).
type Handler func(ctx context.Context, env Envelope) error
// Consume declares binding.Queue durable, binds it to every routing key
// in binding.RoutingKeys, and runs handler for each delivery until ctx
// is cancelled. Blocks the calling goroutine.
func (b *Bus) Consume(ctx context.Context, binding QueueBinding, handler Handler) error {
q, err := b.channel.QueueDeclare(binding.Queue, true, false, false, false, nil)
if err != nil {
return fmt.Errorf("declare queue %s: %w", binding.Queue, err)
}
for _, key := range binding.RoutingKeys {
if err := b.channel.QueueBind(q.Name, key, ExchangeName, false, nil); err != nil {
return fmt.Errorf("bind queue %s to %s: %w", binding.Queue, key, err)
}
}
// Process one message at a time per consumer goroutine — the
// notification service's send-an-email handler is I/O bound on
// SMTP, not CPU bound, so this keeps ordering simple without
// needing a worker pool for the volumes this is sized for.
if err := b.channel.Qos(1, 0, false); err != nil {
return fmt.Errorf("set qos: %w", err)
}
deliveries, err := b.channel.Consume(q.Name, "", false, false, false, false, nil)
if err != nil {
return fmt.Errorf("consume %s: %w", q.Name, err)
}
for {
select {
case <-ctx.Done():
return nil
case d, ok := <-deliveries:
if !ok {
return fmt.Errorf("delivery channel closed for queue %s", q.Name)
}
var env Envelope
if err := json.Unmarshal(d.Body, &env); err != nil {
b.logger.Error("decode event envelope", "queue", q.Name, "err", err)
_ = d.Nack(false, false) // malformed — don't requeue
continue
}
if err := handler(ctx, env); err != nil {
b.logger.Error("handle event", "queue", q.Name, "routing_key", env.RoutingKey, "err", err)
_ = d.Nack(false, true)
continue
}
_ = d.Ack(false)
}
}
}