initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
192
api/internal/events/events.go
Normal file
192
api/internal/events/events.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Package events publishes domain events so other services can react to them
|
||||
// without being called synchronously.
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// Topic names the routing key an event is published under.
|
||||
type Topic string
|
||||
|
||||
const (
|
||||
// TopicPlaceCreated fires once a place exists, before its pictures are
|
||||
// attached.
|
||||
TopicPlaceCreated Topic = "place.created"
|
||||
// TopicImageAnalyzed fires when a picture's badges are available.
|
||||
TopicImageAnalyzed Topic = "image.analyzed"
|
||||
// TopicImageLinked fires when a picture is linked to another taken at the
|
||||
// same place.
|
||||
TopicImageLinked Topic = "image.linked"
|
||||
// TopicCommentAdded fires when a comment is posted.
|
||||
TopicCommentAdded Topic = "comment.added"
|
||||
)
|
||||
|
||||
// Event is the envelope every published message shares. Payload stays raw so
|
||||
// consumers can evolve their own schema without the publisher changing.
|
||||
type Event struct {
|
||||
// ID uniquely identifies this event, letting consumers deduplicate.
|
||||
ID string `json:"id"`
|
||||
// Topic is the routing key.
|
||||
Topic Topic `json:"topic"`
|
||||
// OccurredAt is when the event was produced.
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
// ActorID is the user who caused it, empty for system events.
|
||||
ActorID string `json:"actorId,omitempty"`
|
||||
// Payload is topic-specific JSON.
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Publisher sends events. Implementations must not block the request path for
|
||||
// long; a slow broker should not stall an upload.
|
||||
type Publisher interface {
|
||||
// Publish sends one event. Implementations should treat delivery as
|
||||
// best-effort and report failure to the caller rather than panicking.
|
||||
Publish(ctx context.Context, ev Event) error
|
||||
// Close releases resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// LogPublisher writes events to the structured log. It is the default so the
|
||||
// service runs without a broker, and it keeps the event stream inspectable in
|
||||
// development.
|
||||
type LogPublisher struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ Publisher = (*LogPublisher)(nil)
|
||||
|
||||
// NewLogPublisher returns a publisher that logs events.
|
||||
func NewLogPublisher(logger *slog.Logger) *LogPublisher {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &LogPublisher{logger: logger}
|
||||
}
|
||||
|
||||
// Publish implements Publisher.
|
||||
func (p *LogPublisher) Publish(ctx context.Context, ev Event) error {
|
||||
p.logger.InfoContext(ctx, "event published",
|
||||
"topic", string(ev.Topic),
|
||||
"eventId", ev.ID,
|
||||
"actorId", ev.ActorID,
|
||||
"payload", string(ev.Payload),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close implements Publisher.
|
||||
func (p *LogPublisher) Close() error { return nil }
|
||||
|
||||
// AMQPPublisher publishes to a RabbitMQ topic exchange.
|
||||
type AMQPPublisher struct {
|
||||
conn *amqp.Connection
|
||||
channel *amqp.Channel
|
||||
exchange string
|
||||
logger *slog.Logger
|
||||
// confirms receives publisher acknowledgements, so a broker-side rejection
|
||||
// is noticed instead of being silently dropped.
|
||||
confirms <-chan amqp.Confirmation
|
||||
}
|
||||
|
||||
var _ Publisher = (*AMQPPublisher)(nil)
|
||||
|
||||
// NewAMQP dials the broker, declares the exchange and enables confirms.
|
||||
func NewAMQP(url, exchange string, logger *slog.Logger) (*AMQPPublisher, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("events: dial amqp: %w", err)
|
||||
}
|
||||
channel, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("events: open channel: %w", err)
|
||||
}
|
||||
|
||||
// A topic exchange lets consumers bind to "image.*" or "place.created".
|
||||
if err := channel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil {
|
||||
channel.Close()
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("events: declare exchange %q: %w", exchange, err)
|
||||
}
|
||||
if err := channel.Confirm(false); err != nil {
|
||||
channel.Close()
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("events: enable confirms: %w", err)
|
||||
}
|
||||
|
||||
return &AMQPPublisher{
|
||||
conn: conn,
|
||||
channel: channel,
|
||||
exchange: exchange,
|
||||
logger: logger,
|
||||
confirms: channel.NotifyPublish(make(chan amqp.Confirmation, 1)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Publish implements Publisher. It waits briefly for the broker's
|
||||
// acknowledgement so that a failed publish is reported to the caller.
|
||||
func (p *AMQPPublisher) Publish(ctx context.Context, ev Event) error {
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("events: marshal %s: %w", ev.Topic, err)
|
||||
}
|
||||
|
||||
// Delivery mode 2 marks the message persistent.
|
||||
pub := amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
MessageId: ev.ID,
|
||||
Timestamp: ev.OccurredAt,
|
||||
Type: string(ev.Topic),
|
||||
Body: body,
|
||||
}
|
||||
|
||||
if err := p.channel.PublishWithContext(ctx, p.exchange, string(ev.Topic), false, false, pub); err != nil {
|
||||
return fmt.Errorf("events: publish %s: %w", ev.Topic, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case confirm, ok := <-p.confirms:
|
||||
if !ok {
|
||||
return fmt.Errorf("events: confirmation channel closed for %s", ev.Topic)
|
||||
}
|
||||
if !confirm.Ack {
|
||||
return fmt.Errorf("events: broker rejected %s", ev.Topic)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(confirmTimeout):
|
||||
return fmt.Errorf("events: no confirmation for %s within %s", ev.Topic, confirmTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// confirmTimeout bounds the wait for a broker acknowledgement.
|
||||
const confirmTimeout = 5 * time.Second
|
||||
|
||||
// Close implements Publisher.
|
||||
func (p *AMQPPublisher) Close() error {
|
||||
var firstErr error
|
||||
if p.channel != nil {
|
||||
if err := p.channel.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if p.conn != nil {
|
||||
if err := p.conn.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue