├── .github
├── dependabot.yml
└── workflows
│ └── main.yaml
├── .gitignore
├── LICENSE
├── README.md
├── connection.go
├── consumableEvent.go
├── consumer.go
├── consumerLoop.go
├── consumerOption.go
├── deliveryInfo.go
├── deliveryInfo_test.go
├── go.mod
├── go.sum
├── handler.go
├── logo.png
├── metrics.go
├── notification.go
├── notification_test.go
├── outbox
├── README.md
├── go.mod
├── go.sum
├── internal
│ └── sqlc
│ │ ├── db.go
│ │ ├── models.go
│ │ └── queries.sql.go
├── notification.go
├── publisher.go
├── publisherLoop.go
├── publisherOption.go
├── queries
│ └── queries.sql
├── schema
│ └── schema.sql
├── sqlc.yaml
└── tests
│ ├── consumer_publish_test.go
│ └── docker-compose.yaml
├── publishableEvent.go
├── publisher.go
├── tests
├── consumer_invalid_options_test.go
├── consumer_publish_metrics_test.go
├── consumer_publish_test.go
├── consumer_publish_tracer_test.go
├── consumer_retries_test.go
├── dead_letter_receives_event_test.go
├── docker-compose.yaml
└── go_routines_not_leaked_test.go
└── tracing.go
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | groups:
8 | all-dependencies:
9 | patterns:
10 | - "*"
11 | allow:
12 | - dependency-type: "indirect"
13 | - package-ecosystem: "gomod"
14 | directory: "/outbox/"
15 | schedule:
16 | interval: "weekly"
17 | groups:
18 | all-dependencies:
19 | patterns:
20 | - "*"
21 | allow:
22 | - dependency-type: "indirect"
23 |
--------------------------------------------------------------------------------
/.github/workflows/main.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | tests:
13 | timeout-minutes: 10
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 |
20 | - name: Start dependencies
21 | run: docker compose -f tests/docker-compose.yaml -f outbox/tests/docker-compose.yaml up -d
22 |
23 | - name: Get cover tooling (1/2)
24 | run: go get golang.org/x/tools/cmd/cover
25 |
26 | - name: Get cover tooling (2/2)
27 | run: go get github.com/mattn/goveralls
28 |
29 | - name: Test Bunnify
30 | run: go test -race -coverprofile=profile.bunnify.cov -coverpkg=./... ./...
31 |
32 | - name: Test Outbox
33 | run: cd outbox && go test -race -coverprofile=../profile.outbox.cov -coverpkg=./... ./...
34 |
35 | - name: Merge coverage
36 | run: 'cat profile.outbox.cov | grep -v "mode: atomic" >> profile.bunnify.cov'
37 |
38 | - name: Convert to count mode
39 | run: "sed -i '1s/.*/mode: count/' profile.bunnify.cov"
40 |
41 | - name: Send coverage
42 | uses: shogo82148/actions-goveralls@v1
43 | with:
44 | path-to-profile: profile.bunnify.cov
45 | github-token: ${{ secrets.GITHUB_TOKEN }}
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .DS_Store
3 | local.env
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Pablo Morelli
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | [](https://goreportcard.com/report/github.com/pmorelli92/bunnify)
8 | [](LICENSE)
9 | [](https://github.com/pmorelli92/bunnify/actions/workflows/main.yaml)
10 | [](https://coveralls.io/github/pmorelli92/bunnify?branch=main)
11 |
12 | Bunnify is a library to publish and consume events for AMQP.
13 |
14 |
15 |
16 | > [!IMPORTANT]
17 | > While from my perspective the library is working just fine, I am still changing things here and there. Even tough the library is tagged as semver, I will start respecting it after the `v1.0.0`, and I won't guarantee backwards compatibility before that.
18 |
19 | ## Features
20 |
21 | **Easy setup:** Bunnify is designed to be easy to set up and use. Simply reference the library and start publishing and consuming events.
22 |
23 | **Automatic payload marshaling and unmarshaling:** You can consume the same payload you published, without worrying about the details of marshaling and unmarshaling. Bunnify handles these actions for you, abstracting them away from the developer.
24 |
25 | **Automatic reconnection:** If your connection to the AMQP server is interrupted, Bunnify will automatically handle the reconnection for you. This ensures that your events are published and consumed without interruption.
26 |
27 | **Built-in event metadata handling:** The library automatically handles event metadata, including correlation IDs and other important details.
28 |
29 | **Retries and dead lettering:** You can configure how many times an event can be retried and to send the event to a dead letter queue when the processing fails.
30 |
31 | **Tracing out of the box**: Automatically injects and extracts traces when publishing and consuming. Minimal setup required is shown on the tracer test.
32 |
33 | **Prometheus metrics**: Prometheus gatherer will collect automatically the following metrics:
34 |
35 | - `amqp_events_received`
36 | - `amqp_events_without_handler`
37 | - `amqp_events_not_parsable`
38 | - `amqp_events_nack`
39 | - `amqp_events_processed_duration`
40 | - `amqp_events_publish_succeed`
41 | - `amqp_events_publish_failed`
42 |
43 | **Only dependencies needed:** The intention of the library is to avoid having lots of unneeded dependencies. I will always try to triple check the dependencies and use the least quantity of libraries to achieve the functionality required.
44 |
45 | - `github.com/rabbitmq/amqp091-go`: Handles the connection with AMQP protocol.
46 | - `github.com/google/uuid`: Generates UUID for events ID and correlation ID.
47 | - `go.uber.org/goleak`: Used on tests to verify that there are no leaks of routines on the handling of channels.
48 | - `go.opentelemetry.io/otel`: Handles the injection and extraction of the traces on the events.
49 | - `github.com/prometheus/client_golang`: Used in order to export metrics to Prometheus.
50 |
51 | **Outbox publisher:** There is a submodule that you can refer with `go get github.com/pmorelli92/bunnify/outbox`. This publisher is wrapping the default bunnify publisher and stores all events in a database table which will be looped in an async way to be published to AMQP. You can read more [here](./outbox/README.md).
52 |
53 | ## Motivation
54 |
55 | Every workplace I have been had their own AMQP library. Most of the time the problems that they try to solve are reconnection, logging, correlation, handling the correct body type for events and dead letter. Most of this libraries are good but also built upon some other internal libraries and with some company's specifics that makes them impossible to open source.
56 |
57 | Some developers are often spoiled with these as they provide a good dev experience and that is great; but if you cannot use it in side projects or if you start your own company, what is the point?
58 |
59 | Bunnify aims to provide a flexible and adaptable solution that can be used in a variety of environments and scenarios. By abstracting away many of the technical details of AMQP publishing and consumption, Bunnify makes it easy to get started with event-driven architecture without needing to be an AMQP expert.
60 |
61 | ## Installation
62 |
63 | ```
64 | go get github.com/pmorelli92/bunnify
65 | ```
66 |
67 | ## Examples
68 |
69 | You can find all the working examples under the `tests` folder.
70 |
71 | **Consumer**
72 |
73 | https://github.com/pmorelli92/bunnify/blob/f356a80625d9dcdaec12d05953447ebcc24a1b13/tests/consumer_publish_test.go#L38-L61
74 |
75 | **Dead letter consumer**
76 |
77 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/dead_letter_receives_event_test.go#L34-L67
78 |
79 | **Using a default handler**
80 |
81 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_test.go#L133-L170
82 |
83 | **Publisher**
84 |
85 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_test.go#L64-L78
86 |
87 | **Enable Prometheus metrics**
88 |
89 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_metrics_test.go#L30-L34
90 |
91 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_metrics_test.go#L70-L76
92 |
93 | **Enable tracing**
94 |
95 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_tracer_test.go#L18-L20
96 |
97 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_tracer_test.go#L49-L58
98 |
99 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/tests/consumer_publish_tracer_test.go#L33-L37
100 |
101 | **Retries**
102 |
103 | https://github.com/pmorelli92/bunnify/blob/53c83127f94da86377ae38630e010b9693f376ef/tests/consumer_retries_test.go#L66-L87
104 |
105 | https://github.com/pmorelli92/bunnify/blob/53c83127f94da86377ae38630e010b9693f376ef/tests/consumer_retries_test.go#L66-L87
106 |
107 | **Configuration**
108 |
109 | Both the connection and consumer structs can be configured with the typical functional options. You can find the options below:
110 |
111 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/bunnify/connection.go#L15-L37
112 |
113 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/bunnify/consumerOption.go#L18-L65
114 |
115 | When publishing an event, you can override the event or the correlation ID if you need. This is also achievable with options:
116 |
117 | https://github.com/pmorelli92/bunnify/blob/76f7495ef660fd4c802af8e610ffbc9cca0e39ba/bunnify/publishableEvent.go#L22-L36
118 |
--------------------------------------------------------------------------------
/connection.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "time"
5 |
6 | amqp "github.com/rabbitmq/amqp091-go"
7 | )
8 |
9 | type connectionOption struct {
10 | uri string
11 | reconnectInterval time.Duration
12 | notificationChannel chan<- Notification
13 | }
14 |
15 | // WithURI allows the consumer to specify the AMQP Server.
16 | // It should be in the format of amqp://0.0.0.0:5672
17 | func WithURI(URI string) func(*connectionOption) {
18 | return func(opt *connectionOption) {
19 | opt.uri = URI
20 | }
21 | }
22 |
23 | // WithReconnectInterval establishes how much time to wait
24 | // between each attempt of connection.
25 | func WithReconnectInterval(interval time.Duration) func(*connectionOption) {
26 | return func(opt *connectionOption) {
27 | opt.reconnectInterval = interval
28 | }
29 | }
30 |
31 | // WithNotificationChannel specifies a go channel to receive messages
32 | // such as connection established, reconnecting, event published, consumed, etc.
33 | func WithNotificationChannel(notificationCh chan<- Notification) func(*connectionOption) {
34 | return func(opt *connectionOption) {
35 | opt.notificationChannel = notificationCh
36 | }
37 | }
38 |
39 | // Connection represents a connection towards the AMQP server.
40 | // A single connection should be enough for the entire application as the
41 | // consuming and publishing is handled by channels.
42 | type Connection struct {
43 | options connectionOption
44 | connection *amqp.Connection
45 | connectionClosedBySystem bool
46 | }
47 |
48 | // NewConnection creates a new AMQP connection using the indicated
49 | // options. If the consumer does not supply options, it will by default
50 | // connect to a localhost instance on and try to reconnect every 10 seconds.
51 | func NewConnection(opts ...func(*connectionOption)) *Connection {
52 | options := connectionOption{
53 | reconnectInterval: 10 * time.Second,
54 | uri: "amqp://localhost:5672",
55 | }
56 | for _, opt := range opts {
57 | opt(&options)
58 | }
59 | return &Connection{
60 | options: options,
61 | }
62 | }
63 |
64 | // Start establishes the connection towards the AMQP server.
65 | // Only returns errors when the uri is not valid (retry won't do a thing)
66 | func (c *Connection) Start() error {
67 | var err error
68 | var conn *amqp.Connection
69 | ticker := time.NewTicker(c.options.reconnectInterval)
70 |
71 | uri, err := amqp.ParseURI(c.options.uri)
72 | if err != nil {
73 | return err
74 | }
75 |
76 | for {
77 | conn, err = amqp.Dial(uri.String())
78 | if err == nil {
79 | break
80 | }
81 |
82 | notifyConnectionFailed(c.options.notificationChannel, err)
83 | <-ticker.C
84 | }
85 |
86 | c.connection = conn
87 | notifyConnectionEstablished(c.options.notificationChannel)
88 |
89 | go func() {
90 | <-conn.NotifyClose(make(chan *amqp.Error))
91 | if !c.connectionClosedBySystem {
92 | notifyConnectionLost(c.options.notificationChannel)
93 | _ = c.Start()
94 | }
95 | }()
96 |
97 | return nil
98 | }
99 |
100 | // Closes connection with towards the AMQP server
101 | func (c *Connection) Close() error {
102 | c.connectionClosedBySystem = true
103 | if c.connection != nil {
104 | notifyClosingConnection(c.options.notificationChannel)
105 | return c.connection.Close()
106 | }
107 | return nil
108 | }
109 |
110 | func (c *Connection) getNewChannel(source NotificationSource) (*amqp.Channel, bool) {
111 | if c.connectionClosedBySystem {
112 | notifyConnectionClosedBySystem(c.options.notificationChannel)
113 | return nil, true
114 | }
115 |
116 | var err error
117 | var ch *amqp.Channel
118 | ticker := time.NewTicker(c.options.reconnectInterval)
119 |
120 | for {
121 | ch, err = c.connection.Channel()
122 | if err == nil {
123 | break
124 | }
125 |
126 | notifyChannelFailed(c.options.notificationChannel, source, err)
127 | <-ticker.C
128 | }
129 |
130 | notifyChannelEstablished(c.options.notificationChannel, source)
131 | return ch, false
132 | }
133 |
--------------------------------------------------------------------------------
/consumableEvent.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | // Metadata holds the metadata of an event.
9 | type Metadata struct {
10 | ID string `json:"id"`
11 | CorrelationID string `json:"correlationId"`
12 | Timestamp time.Time `json:"timestamp"`
13 | }
14 |
15 | // DeliveryInfo holds information of original queue, exchange and routing keys.
16 | type DeliveryInfo struct {
17 | Queue string
18 | Exchange string
19 | RoutingKey string
20 | }
21 |
22 | // ConsumableEvent[T] represents an event that can be consumed.
23 | // The type parameter T specifies the type of the event's payload.
24 | type ConsumableEvent[T any] struct {
25 | Metadata
26 | DeliveryInfo DeliveryInfo
27 | Payload T
28 | }
29 |
30 | // unmarshalEvent is used internally to unmarshal a PublishableEvent
31 | // this way the payload ends up being a json.RawMessage instead of map[string]interface{}
32 | // so that later the json.RawMessage can be unmarshal to ConsumableEvent[T].Payload.
33 | type unmarshalEvent struct {
34 | Metadata
35 | DeliveryInfo DeliveryInfo
36 | Payload json.RawMessage `json:"payload"`
37 | }
38 |
--------------------------------------------------------------------------------
/consumer.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | amqp "github.com/rabbitmq/amqp091-go"
8 | )
9 |
10 | // Consumer is used for consuming to events from an specified queue.
11 | type Consumer struct {
12 | queueName string
13 | initialized bool
14 | options consumerOption
15 | getNewChannel func() (*amqp.Channel, bool)
16 | }
17 |
18 | // NewConsumer creates a consumer for a given queue using the specified connection.
19 | // Information messages such as channel status will be sent to the notification channel
20 | // if it was specified on the connection struct.
21 | // If no QoS is supplied the prefetch count will be of 20.
22 | func (c *Connection) NewConsumer(
23 | queueName string,
24 | opts ...func(*consumerOption)) Consumer {
25 |
26 | options := consumerOption{
27 | notificationCh: c.options.notificationChannel,
28 | handlers: make(map[string]wrappedHandler, 0),
29 | prefetchCount: 20,
30 | prefetchSize: 0,
31 | }
32 | for _, opt := range opts {
33 | opt(&options)
34 | }
35 |
36 | return Consumer{
37 | queueName: queueName,
38 | options: options,
39 | getNewChannel: func() (*amqp.Channel, bool) {
40 | return c.getNewChannel(NotificationSourceConsumer)
41 | },
42 | }
43 | }
44 |
45 | // AddHandlerToConsumer adds a handler for the given routing key.
46 | // It is another way to add handlers when the consumer is already created and cannot use the options.
47 | func AddHandlerToConsumer[T any](consumer *Consumer, routingKey string, handler EventHandler[T]) {
48 | consumer.options.handlers[routingKey] = newWrappedHandler(handler)
49 | }
50 |
51 | // Consume will start consuming events from the indicated queue.
52 | // The first time this function is called it will return error if
53 | // handlers or default handler are not specified or if queues, exchanges,
54 | // bindings and qos creation don't succeed. In case this function gets called
55 | // recursively due to channel reconnection, the errors will be pushed to
56 | // the notification channel (if one has been indicated in the connection).
57 | func (c *Consumer) Consume() error {
58 | return c.consume(false)
59 | }
60 |
61 | // ConsumeParallel will start consuming events for the indicated queue.
62 | // The first time this function is called it will return error if
63 | // handlers or default handler are not specified and also if queues, exchanges,
64 | // bindings or qos creation don't succeed. In case this function gets called
65 | // recursively due to channel reconnection, the errors will be pushed to
66 | // the notification channel (if one has been indicated in the connection).
67 | // The difference between this and the regular Consume is that this one fires
68 | // a go routine per each message received as opposed of sequentially.
69 | func (c *Consumer) ConsumeParallel() error {
70 | return c.consume(true)
71 | }
72 |
73 | func (c *Consumer) consume(parallel bool) error {
74 | channel, connectionClosed := c.getNewChannel()
75 | if connectionClosed {
76 | return fmt.Errorf("connection is already closed by system")
77 | }
78 |
79 | // If obtained channel is closed, try again
80 | if channel.IsClosed() {
81 | return c.consume(parallel)
82 | }
83 |
84 | if !c.initialized {
85 | if c.options.defaultHandler == nil && len(c.options.handlers) == 0 {
86 | return fmt.Errorf("no handlers specified")
87 | }
88 |
89 | if err := c.createExchanges(channel); err != nil {
90 | return fmt.Errorf("failed to declare exchange: %w", err)
91 | }
92 |
93 | if err := c.createQueues(channel); err != nil {
94 | return fmt.Errorf("failed to declare queue: %w", err)
95 | }
96 |
97 | if err := c.queueBind(channel); err != nil {
98 | return fmt.Errorf("failed to bind queue: %w", err)
99 | }
100 |
101 | c.initialized = true
102 | }
103 |
104 | if err := channel.Qos(c.options.prefetchCount, c.options.prefetchSize, false); err != nil {
105 | return fmt.Errorf("failed to set qos: %w", err)
106 | }
107 |
108 | deliveries, err := channel.Consume(c.queueName, "", false, false, false, false, nil)
109 | if err != nil {
110 | return fmt.Errorf("failed to establish consuming from queue: %w", err)
111 | }
112 |
113 | if parallel {
114 | go c.parallelLoop(channel, deliveries)
115 | } else {
116 | go c.loop(channel, deliveries)
117 | }
118 |
119 | return nil
120 | }
121 |
122 | func (c *Consumer) createExchanges(channel *amqp.Channel) error {
123 | errs := make([]error, 0)
124 |
125 | if c.options.exchange != "" {
126 | errs = append(errs, channel.ExchangeDeclare(
127 | c.options.exchange,
128 | "direct",
129 | true, // durable
130 | false, // auto-deleted
131 | false, // internal
132 | false, // no-wait
133 | nil, // args
134 | ))
135 | }
136 |
137 | if c.options.deadLetterQueue != "" {
138 | errs = append(errs, channel.ExchangeDeclare(
139 | fmt.Sprintf("%s-exchange", c.options.deadLetterQueue),
140 | "direct",
141 | true, // durable
142 | false, // auto-deleted
143 | false, // internal
144 | false, // no-wait
145 | nil, // args
146 | ))
147 | }
148 |
149 | return errors.Join(errs...)
150 | }
151 |
152 | func (c *Consumer) createQueues(channel *amqp.Channel) error {
153 | errs := make([]error, 0)
154 |
155 | amqpTable := amqp.Table{}
156 |
157 | if c.options.quorumQueue {
158 | amqpTable["x-queue-type"] = "quorum"
159 | }
160 |
161 | if c.options.retries > 0 {
162 | if !c.options.quorumQueue {
163 | return fmt.Errorf("to enable retries, you need to use quorum queues.")
164 | }
165 | }
166 |
167 | if c.options.deadLetterQueue != "" {
168 | amqpTable["x-dead-letter-exchange"] = fmt.Sprintf("%s-exchange", c.options.deadLetterQueue)
169 | amqpTable["x-dead-letter-routing-key"] = ""
170 |
171 | _, err := channel.QueueDeclare(
172 | c.options.deadLetterQueue,
173 | true, // durable
174 | false, // auto-delete
175 | false, // exclusive
176 | false, // no-wait
177 | amqp.Table{},
178 | )
179 | errs = append(errs, err)
180 | }
181 |
182 | _, err := channel.QueueDeclare(
183 | c.queueName,
184 | true, // durable
185 | false, // auto-delete
186 | false, // exclusive
187 | false, // no-wait
188 | amqpTable,
189 | )
190 | errs = append(errs, err)
191 |
192 | return errors.Join(errs...)
193 | }
194 |
195 | func (c *Consumer) queueBind(channel *amqp.Channel) error {
196 | errs := make([]error, 0)
197 |
198 | if c.options.exchange != "" {
199 | for routingKey := range c.options.handlers {
200 | errs = append(errs, channel.QueueBind(
201 | c.queueName,
202 | routingKey,
203 | c.options.exchange,
204 | false,
205 | nil,
206 | ))
207 | }
208 | }
209 |
210 | if c.options.deadLetterQueue != "" {
211 | errs = append(errs, channel.QueueBind(
212 | c.options.deadLetterQueue,
213 | "",
214 | fmt.Sprintf("%s-exchange", c.options.deadLetterQueue),
215 | false,
216 | nil,
217 | ))
218 | }
219 |
220 | return errors.Join(errs...)
221 | }
222 |
--------------------------------------------------------------------------------
/consumerLoop.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "encoding/json"
5 | "sync"
6 | "time"
7 |
8 | amqp "github.com/rabbitmq/amqp091-go"
9 | )
10 |
11 | func (c *Consumer) loop(channel *amqp.Channel, deliveries <-chan amqp.Delivery) {
12 | mutex := sync.Mutex{}
13 | for delivery := range deliveries {
14 | c.handle(delivery, &mutex)
15 | }
16 |
17 | // If the for exits, it means the channel stopped.
18 | // Close it, notify the error and start the consumer so it will start another loop.
19 | if !channel.IsClosed() {
20 | channel.Close()
21 | }
22 |
23 | notifyChannelLost(c.options.notificationCh, NotificationSourceConsumer)
24 |
25 | if err := c.Consume(); err != nil {
26 | notifyChannelFailed(c.options.notificationCh, NotificationSourceConsumer, err)
27 | }
28 | }
29 |
30 | func (c *Consumer) parallelLoop(channel *amqp.Channel, deliveries <-chan amqp.Delivery) {
31 | mutex := sync.Mutex{}
32 | for delivery := range deliveries {
33 | go c.handle(delivery, &mutex)
34 | }
35 |
36 | // If the for exits, it means the channel stopped.
37 | // Close it, notify the error and start the consumer so it will start another loop.
38 | if !channel.IsClosed() {
39 | channel.Close()
40 | }
41 |
42 | notifyChannelLost(c.options.notificationCh, NotificationSourceConsumer)
43 |
44 | if err := c.ConsumeParallel(); err != nil {
45 | notifyChannelFailed(c.options.notificationCh, NotificationSourceConsumer, err)
46 | }
47 | }
48 |
49 | func (c *Consumer) handle(delivery amqp.Delivery, mutex *sync.Mutex) {
50 | startTime := time.Now()
51 | deliveryInfo := getDeliveryInfo(c.queueName, delivery)
52 | eventReceived(c.queueName, deliveryInfo.RoutingKey)
53 |
54 | // Establish which handler is invoked
55 | mutex.Lock()
56 | handler, ok := c.options.handlers[deliveryInfo.RoutingKey]
57 | mutex.Unlock()
58 | if !ok {
59 | if c.options.defaultHandler == nil {
60 | _ = delivery.Nack(false, false)
61 | notifyEventHandlerNotFound(c.options.notificationCh, deliveryInfo.RoutingKey)
62 | eventWithoutHandler(c.queueName, deliveryInfo.RoutingKey)
63 | return
64 | }
65 | handler = c.options.defaultHandler
66 | }
67 |
68 | uevt := unmarshalEvent{DeliveryInfo: deliveryInfo}
69 |
70 | // For this error to happen an event not published by Bunnify is required
71 | if err := json.Unmarshal(delivery.Body, &uevt); err != nil {
72 | _ = delivery.Nack(false, false)
73 | eventNotParsable(c.queueName, deliveryInfo.RoutingKey)
74 | return
75 | }
76 |
77 | tracingCtx := extractToContext(delivery.Headers)
78 | if err := handler(tracingCtx, uevt); err != nil {
79 | elapsed := time.Since(startTime).Milliseconds()
80 | notifyEventHandlerFailed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed, err)
81 | _ = delivery.Nack(false, c.shouldRetry(delivery.Headers))
82 | eventNack(c.queueName, deliveryInfo.RoutingKey, elapsed)
83 | return
84 | }
85 |
86 | elapsed := time.Since(startTime).Milliseconds()
87 | notifyEventHandlerSucceed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed)
88 | _ = delivery.Ack(false)
89 | eventAck(c.queueName, deliveryInfo.RoutingKey, elapsed)
90 | }
91 |
92 | func (c *Consumer) shouldRetry(headers amqp.Table) bool {
93 | if c.options.retries <= 0 {
94 | return false
95 | }
96 |
97 | // before the first retry, the delivery count is not present (so 0)
98 | retries, ok := headers["x-delivery-count"]
99 | if !ok {
100 | return true
101 | }
102 |
103 | r, _ := retries.(int64)
104 | return c.options.retries > int(r)
105 | }
106 |
--------------------------------------------------------------------------------
/consumerOption.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "encoding/json"
5 | )
6 |
7 | type consumerOption struct {
8 | deadLetterQueue string
9 | exchange string
10 | defaultHandler wrappedHandler
11 | handlers map[string]wrappedHandler
12 | prefetchCount int
13 | prefetchSize int
14 | quorumQueue bool
15 | notificationCh chan<- Notification
16 | retries int
17 | }
18 |
19 | // WithBindingToExchange specifies the exchange on which the queue
20 | // will bind for the handlers provided.
21 | func WithBindingToExchange(exchange string) func(*consumerOption) {
22 | return func(opt *consumerOption) {
23 | opt.exchange = exchange
24 | }
25 | }
26 |
27 | // WithQoS specifies the prefetch count and size for the consumer.
28 | func WithQoS(prefetchCount, prefetchSize int) func(*consumerOption) {
29 | return func(opt *consumerOption) {
30 | opt.prefetchCount = prefetchCount
31 | opt.prefetchSize = prefetchSize
32 | }
33 | }
34 |
35 | // WithQuorumQueue specifies that the queue to consume will be created as quorum queue.
36 | // Quorum queues are used when data safety is the priority.
37 | func WithQuorumQueue() func(*consumerOption) {
38 | return func(opt *consumerOption) {
39 | opt.quorumQueue = true
40 | }
41 | }
42 |
43 | // WithRetries specifies the retries count before the event is discarded or sent to dead letter.
44 | // Quorum queues are required to use this feature.
45 | // The event will be processed at max as retries + 1.
46 | // If specified amount is 3, the event can be processed up to 4 times.
47 | func WithRetries(retries int) func(*consumerOption) {
48 | return func(opt *consumerOption) {
49 | opt.retries = retries
50 | }
51 | }
52 |
53 | // WithDeadLetterQueue indicates which queue will receive the events
54 | // that were NACKed for this consumer.
55 | func WithDeadLetterQueue(queueName string) func(*consumerOption) {
56 | return func(opt *consumerOption) {
57 | opt.deadLetterQueue = queueName
58 | }
59 | }
60 |
61 | // WithDefaultHandler specifies a handler that can be use for any type
62 | // of routing key without a defined handler. This is mostly convenient if you
63 | // don't care about the specific payload of the event, which will be received as a byte array.
64 | func WithDefaultHandler(handler EventHandler[json.RawMessage]) func(*consumerOption) {
65 | return func(opt *consumerOption) {
66 | opt.defaultHandler = newWrappedHandler(handler)
67 | }
68 | }
69 |
70 | // WithHandler specifies under which routing key the provided handler will be invoked.
71 | // The routing key indicated here will be bound to the queue if the WithBindingToExchange is supplied.
72 | func WithHandler[T any](routingKey string, handler EventHandler[T]) func(*consumerOption) {
73 | return func(opt *consumerOption) {
74 | opt.handlers[routingKey] = newWrappedHandler(handler)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/deliveryInfo.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | amqp "github.com/rabbitmq/amqp091-go"
5 | )
6 |
7 | func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo {
8 | deliveryInfo := DeliveryInfo{
9 | Queue: queueName,
10 | Exchange: delivery.Exchange,
11 | RoutingKey: delivery.RoutingKey,
12 | }
13 |
14 | // If routing key is empty, it is mostly due to the event being dead lettered.
15 | // Check for the original delivery information in the headers
16 | if delivery.RoutingKey == "" {
17 | deaths, ok := delivery.Headers["x-death"].([]interface{})
18 | if !ok || len(deaths) == 0 {
19 | return deliveryInfo
20 | }
21 |
22 | death, ok := deaths[0].(amqp.Table)
23 | if !ok {
24 | return deliveryInfo
25 | }
26 |
27 | queue, ok := death["queue"].(string)
28 | if !ok {
29 | return deliveryInfo
30 | }
31 | deliveryInfo.Queue = queue
32 |
33 | exchange, ok := death["exchange"].(string)
34 | if !ok {
35 | return deliveryInfo
36 | }
37 | deliveryInfo.Exchange = exchange
38 |
39 | routingKeys, ok := death["routing-keys"].([]interface{})
40 | if !ok || len(routingKeys) == 0 {
41 | return deliveryInfo
42 | }
43 | key, ok := routingKeys[0].(string)
44 | if !ok {
45 | return deliveryInfo
46 | }
47 | deliveryInfo.RoutingKey = key
48 | }
49 |
50 | return deliveryInfo
51 | }
52 |
--------------------------------------------------------------------------------
/deliveryInfo_test.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/google/uuid"
7 | "github.com/rabbitmq/amqp091-go"
8 | )
9 |
10 | func TestGetDeliveryInfo(t *testing.T) {
11 | t.Run("When event gets processed first time", func(t *testing.T) {
12 | // Setup
13 | queueName := uuid.NewString()
14 | exchange := uuid.NewString()
15 | routingKey := uuid.NewString()
16 |
17 | // Exercise
18 | info := getDeliveryInfo(queueName, amqp091.Delivery{
19 | Exchange: exchange,
20 | RoutingKey: routingKey,
21 | })
22 |
23 | // Assert
24 | if queueName != info.Queue {
25 | t.Fatalf("expected queue %s, got %s", queueName, info.Queue)
26 | }
27 | if exchange != info.Exchange {
28 | t.Fatalf("expected exchange %s, got %s", exchange, info.Exchange)
29 | }
30 | if routingKey != info.RoutingKey {
31 | t.Fatalf("expected routing key %s, got %s", routingKey, info.RoutingKey)
32 | }
33 | })
34 |
35 | t.Run("When event has been sent to dead queue", func(t *testing.T) {
36 | // Setup
37 | queueName := uuid.NewString()
38 | exchange := uuid.NewString()
39 | routingKey := uuid.NewString()
40 |
41 | // Exercise
42 | info := getDeliveryInfo(queueName, amqp091.Delivery{
43 | Headers: map[string]interface{}{
44 | "x-death": []interface{}{
45 | amqp091.Table{
46 | "queue": queueName,
47 | "exchange": exchange,
48 | "routing-keys": []interface{}{
49 | routingKey,
50 | },
51 | },
52 | },
53 | },
54 | Exchange: "dead-exchange",
55 | RoutingKey: "",
56 | })
57 |
58 | // Assert
59 | if queueName != info.Queue {
60 | t.Fatalf("expected queue %s, got %s", queueName, info.Queue)
61 | }
62 | if exchange != info.Exchange {
63 | t.Fatalf("expected exchange %s, got %s", exchange, info.Exchange)
64 | }
65 | if routingKey != info.RoutingKey {
66 | t.Fatalf("expected routing key %s, got %s", routingKey, info.RoutingKey)
67 | }
68 | })
69 | }
70 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/pmorelli92/bunnify
2 |
3 | go 1.22.0
4 |
5 | toolchain go1.23.4
6 |
7 | require (
8 | github.com/google/uuid v1.6.0
9 | github.com/prometheus/client_golang v1.20.5
10 | github.com/rabbitmq/amqp091-go v1.10.0
11 | go.opentelemetry.io/otel v1.34.0
12 | go.opentelemetry.io/otel/sdk v1.34.0
13 | go.opentelemetry.io/otel/trace v1.34.0
14 | go.uber.org/goleak v1.3.0
15 | )
16 |
17 | require (
18 | github.com/beorn7/perks v1.0.1 // indirect
19 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
20 | github.com/go-logr/logr v1.4.2 // indirect
21 | github.com/go-logr/stdr v1.2.2 // indirect
22 | github.com/kylelemons/godebug v1.1.0 // indirect
23 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
24 | github.com/prometheus/client_model v0.6.1 // indirect
25 | github.com/prometheus/common v0.62.0 // indirect
26 | github.com/prometheus/procfs v0.15.1 // indirect
27 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect
28 | go.opentelemetry.io/otel/metric v1.34.0 // indirect
29 | golang.org/x/sys v0.29.0 // indirect
30 | google.golang.org/protobuf v1.36.4 // indirect
31 | )
32 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
8 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
9 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
10 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
11 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
12 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
14 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
15 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
16 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
17 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
18 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
19 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
21 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
24 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
25 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
26 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
27 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
28 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
29 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
30 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
31 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
32 | github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
33 | github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
34 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
35 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
36 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
37 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
38 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
39 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
40 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
41 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
42 | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
43 | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
44 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
45 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
46 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
47 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
48 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
49 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
50 | google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
51 | google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
52 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
53 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
54 |
--------------------------------------------------------------------------------
/handler.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | )
7 |
8 | // EventHandler is the type definition for a function that is used to handle events of a specific type.
9 | type EventHandler[T any] func(ctx context.Context, event ConsumableEvent[T]) error
10 |
11 | // wrappedHandler is internally used to wrap the generic EventHandler
12 | // this is to facilitate adding all the different type of T on the same map
13 | type wrappedHandler func(ctx context.Context, event unmarshalEvent) error
14 |
15 | func newWrappedHandler[T any](handler EventHandler[T]) wrappedHandler {
16 | return func(ctx context.Context, event unmarshalEvent) error {
17 | consumableEvent := ConsumableEvent[T]{
18 | Metadata: event.Metadata,
19 | DeliveryInfo: event.DeliveryInfo,
20 | }
21 | err := json.Unmarshal(event.Payload, &consumableEvent.Payload)
22 | if err != nil {
23 | return err
24 | }
25 | return handler(ctx, consumableEvent)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmorelli92/bunnify/24f4d298b95c37cf8e1fc9c432e9edc35178105d/logo.png
--------------------------------------------------------------------------------
/metrics.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/prometheus/client_golang/prometheus"
7 | )
8 |
9 | const (
10 | queue = "queue"
11 | exchange = "exchange"
12 | result = "result"
13 | routingKey = "routing_key"
14 | )
15 |
16 | var (
17 | eventReceivedCounter = prometheus.NewCounterVec(
18 | prometheus.CounterOpts{
19 | Name: "amqp_events_received",
20 | Help: "Count of AMQP events received",
21 | }, []string{queue, routingKey},
22 | )
23 |
24 | eventWithoutHandlerCounter = prometheus.NewCounterVec(
25 | prometheus.CounterOpts{
26 | Name: "amqp_events_without_handler",
27 | Help: "Count of AMQP events without a handler",
28 | }, []string{queue, routingKey},
29 | )
30 |
31 | eventNotParsableCounter = prometheus.NewCounterVec(
32 | prometheus.CounterOpts{
33 | Name: "amqp_events_not_parsable",
34 | Help: "Count of AMQP events that could not be parsed",
35 | }, []string{queue, routingKey},
36 | )
37 |
38 | eventNackCounter = prometheus.NewCounterVec(
39 | prometheus.CounterOpts{
40 | Name: "amqp_events_nack",
41 | Help: "Count of AMQP events that were not acknowledged",
42 | }, []string{queue, routingKey},
43 | )
44 |
45 | eventAckCounter = prometheus.NewCounterVec(
46 | prometheus.CounterOpts{
47 | Name: "amqp_events_ack",
48 | Help: "Count of AMQP events that were acknowledged",
49 | }, []string{queue, routingKey},
50 | )
51 |
52 | eventProcessedDuration = prometheus.NewHistogramVec(
53 | prometheus.HistogramOpts{
54 | Name: "amqp_events_processed_duration",
55 | Help: "Milliseconds taken to process an event",
56 | Buckets: []float64{100, 200, 500, 1000, 3000, 5000, 10000},
57 | }, []string{queue, routingKey, result},
58 | )
59 |
60 | eventPublishSucceedCounter = prometheus.NewCounterVec(
61 | prometheus.CounterOpts{
62 | Name: "amqp_events_publish_succeed",
63 | Help: "Count of AMQP events that could be published successfully",
64 | }, []string{exchange, routingKey},
65 | )
66 |
67 | eventPublishFailedCounter = prometheus.NewCounterVec(
68 | prometheus.CounterOpts{
69 | Name: "amqp_events_publish_failed",
70 | Help: "Count of AMQP events that could not be published",
71 | }, []string{exchange, routingKey},
72 | )
73 | )
74 |
75 | func eventReceived(queue string, routingKey string) {
76 | eventReceivedCounter.WithLabelValues(queue, routingKey).Inc()
77 | }
78 |
79 | func eventWithoutHandler(queue string, routingKey string) {
80 | eventWithoutHandlerCounter.WithLabelValues(queue, routingKey).Inc()
81 | }
82 |
83 | func eventNotParsable(queue string, routingKey string) {
84 | eventNotParsableCounter.WithLabelValues(queue, routingKey).Inc()
85 | }
86 |
87 | func eventNack(queue string, routingKey string, milliseconds int64) {
88 | eventNackCounter.WithLabelValues(queue, routingKey).Inc()
89 |
90 | eventProcessedDuration.
91 | WithLabelValues(queue, routingKey, "NACK").
92 | Observe(float64(milliseconds))
93 | }
94 |
95 | func eventAck(queue string, routingKey string, milliseconds int64) {
96 | eventAckCounter.WithLabelValues(queue, routingKey).Inc()
97 |
98 | eventProcessedDuration.
99 | WithLabelValues(queue, routingKey, "ACK").
100 | Observe(float64(milliseconds))
101 | }
102 |
103 | func eventPublishSucceed(exchange string, routingKey string) {
104 | eventPublishSucceedCounter.WithLabelValues(exchange, routingKey).Inc()
105 | }
106 |
107 | func eventPublishFailed(exchange string, routingKey string) {
108 | eventPublishFailedCounter.WithLabelValues(exchange, routingKey).Inc()
109 | }
110 |
111 | func InitMetrics(registerer prometheus.Registerer) error {
112 | collectors := []prometheus.Collector{
113 | eventReceivedCounter,
114 | eventAckCounter,
115 | eventNackCounter,
116 | eventWithoutHandlerCounter,
117 | eventNotParsableCounter,
118 | eventProcessedDuration,
119 | eventPublishSucceedCounter,
120 | eventPublishFailedCounter,
121 | }
122 | for _, collector := range collectors {
123 | mv, ok := collector.(metricResetter)
124 | if ok {
125 | mv.Reset()
126 | }
127 | err := registerer.Register(collector)
128 | if err != nil && !errors.As(err, &prometheus.AlreadyRegisteredError{}) {
129 | return err
130 | }
131 | }
132 | return nil
133 | }
134 |
135 | // CounterVec, MetricVec and HistogramVec have a Reset func
136 | // in order not to cast to each specific type, metricResetter can
137 | // be used to just get access to the Reset func
138 | type metricResetter interface {
139 | Reset()
140 | }
141 |
--------------------------------------------------------------------------------
/notification.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import "fmt"
4 |
5 | type NotificationSource string
6 |
7 | const (
8 | NotificationSourceConnection NotificationSource = "CONNECTION"
9 | NotificationSourceConsumer NotificationSource = "CONSUMER"
10 | NotificationSourcePublisher NotificationSource = "PUBLISHER"
11 | )
12 |
13 | type NotificationType string
14 |
15 | const (
16 | NotificationTypeInfo NotificationType = "INFO"
17 | NotificationTypeError NotificationType = "ERROR"
18 | )
19 |
20 | type Notification struct {
21 | Message string
22 | Type NotificationType
23 | Source NotificationSource
24 | }
25 |
26 | func (n Notification) String() string {
27 | return fmt.Sprintf("[%s][%s] %s", n.Type, n.Source, n.Message)
28 | }
29 |
30 | func notifyConnectionEstablished(ch chan<- Notification) {
31 | if ch != nil {
32 | ch <- Notification{
33 | Type: NotificationTypeInfo,
34 | Message: "established connection to server",
35 | Source: NotificationSourceConnection,
36 | }
37 | }
38 | }
39 |
40 | func notifyConnectionLost(ch chan<- Notification) {
41 | if ch != nil {
42 | ch <- Notification{
43 | Type: NotificationTypeError,
44 | Message: "lost connection to server, will attempt to reconnect",
45 | Source: NotificationSourceConnection,
46 | }
47 | }
48 | }
49 |
50 | func notifyConnectionFailed(ch chan<- Notification, err error) {
51 | if ch != nil {
52 | ch <- Notification{
53 | Type: NotificationTypeError,
54 | Message: fmt.Sprintf("failed to connect to server, error %s", err),
55 | Source: NotificationSourceConnection,
56 | }
57 | }
58 | }
59 |
60 | func notifyClosingConnection(ch chan<- Notification) {
61 | if ch != nil {
62 | ch <- Notification{
63 | Type: NotificationTypeInfo,
64 | Message: "closing connection to server",
65 | Source: NotificationSourceConnection,
66 | }
67 | }
68 | }
69 |
70 | func notifyConnectionClosedBySystem(ch chan<- Notification) {
71 | if ch != nil {
72 | ch <- Notification{
73 | Type: NotificationTypeInfo,
74 | Message: "connection closed by system, channel will not reconnect",
75 | Source: NotificationSourceConnection,
76 | }
77 | }
78 | }
79 |
80 | func notifyChannelEstablished(ch chan<- Notification, source NotificationSource) {
81 | if ch != nil {
82 | ch <- Notification{
83 | Type: NotificationTypeInfo,
84 | Message: "established connection to channel",
85 | Source: source,
86 | }
87 | }
88 | }
89 |
90 | func notifyChannelLost(ch chan<- Notification, source NotificationSource) {
91 | if ch != nil {
92 | ch <- Notification{
93 | Type: NotificationTypeError,
94 | Message: "lost connection to channel, will attempt to reconnect",
95 | Source: source,
96 | }
97 | }
98 | }
99 |
100 | func notifyChannelFailed(ch chan<- Notification, source NotificationSource, err error) {
101 | if ch != nil {
102 | ch <- Notification{
103 | Type: NotificationTypeError,
104 | Message: fmt.Sprintf("failed to connect to channel, error %s", err),
105 | Source: source,
106 | }
107 | }
108 | }
109 |
110 | func notifyEventHandlerNotFound(ch chan<- Notification, routingKey string) {
111 | if ch != nil {
112 | ch <- Notification{
113 | Type: NotificationTypeError,
114 | Message: fmt.Sprintf("event handler for %s was not found", routingKey),
115 | Source: NotificationSourceConsumer,
116 | }
117 | }
118 | }
119 |
120 | func notifyEventHandlerSucceed(ch chan<- Notification, routingKey string, took int64) {
121 | if ch != nil {
122 | ch <- Notification{
123 | Type: NotificationTypeInfo,
124 | Message: fmt.Sprintf("event handler for %s succeeded, took %d milliseconds", routingKey, took),
125 | Source: NotificationSourceConsumer,
126 | }
127 | }
128 | }
129 |
130 | func notifyEventHandlerFailed(ch chan<- Notification, routingKey string, took int64, err error) {
131 | if ch != nil {
132 | ch <- Notification{
133 | Type: NotificationTypeError,
134 | Message: fmt.Sprintf("event handler for %s failed, took %d milliseconds, error: %s", routingKey, took, err),
135 | Source: NotificationSourceConsumer,
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/notification_test.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | )
7 |
8 | func TestNotifications(t *testing.T) {
9 | // Setup
10 | ch := make(chan Notification, 11)
11 |
12 | // Exercise
13 | notifyConnectionEstablished(ch)
14 | notifyConnectionLost(ch)
15 | notifyConnectionFailed(ch, fmt.Errorf("error"))
16 | notifyClosingConnection(ch)
17 | notifyConnectionClosedBySystem(ch)
18 | notifyChannelEstablished(ch, NotificationSourceConnection)
19 | notifyChannelLost(ch, NotificationSourceConnection)
20 | notifyChannelFailed(ch, NotificationSourceConnection, fmt.Errorf("error"))
21 | notifyEventHandlerSucceed(ch, "routing", 10)
22 | notifyEventHandlerFailed(ch, "routing", 20, fmt.Errorf("error"))
23 | notifyEventHandlerNotFound(ch, "routing")
24 |
25 | // Assert
26 | if (<-ch).Type != NotificationTypeInfo {
27 | t.Fatal("expected notification type info")
28 | }
29 | if (<-ch).Type != NotificationTypeError {
30 | t.Fatal("expected notification type error")
31 | }
32 | if (<-ch).Type != NotificationTypeError {
33 | t.Fatal("expected notification type error")
34 | }
35 | if (<-ch).Type != NotificationTypeInfo {
36 | t.Fatal("expected notification type info")
37 | }
38 | if (<-ch).Type != NotificationTypeInfo {
39 | t.Fatal("expected notification type info")
40 | }
41 | if (<-ch).Type != NotificationTypeInfo {
42 | t.Fatal("expected notification type info")
43 | }
44 | if (<-ch).Type != NotificationTypeError {
45 | t.Fatal("expected notification type error")
46 | }
47 | if (<-ch).Type != NotificationTypeError {
48 | t.Fatal("expected notification type error")
49 | }
50 | if (<-ch).Type != NotificationTypeInfo {
51 | t.Fatal("expected notification type info")
52 | }
53 | if (<-ch).Type != NotificationTypeError {
54 | t.Fatal("expected notification type error")
55 | }
56 | if (<-ch).Type != NotificationTypeError {
57 | t.Fatal("expected notification type error")
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/outbox/README.md:
--------------------------------------------------------------------------------
1 | ## Outbox
2 |
3 | > [!IMPORTANT]
4 | > This submodule is couple with `github.com/jackc/pgx/v5` and therefore works for Postgres databases only. This is the reason why this is shipped in a submodule instead of being included in the base module.
5 |
6 | This submodule implements the [outbox pattern](https://microservices.io/patterns/data/transactional-outbox.html) on top of the Bunnify publisher.
7 |
8 | ## How does it work
9 |
10 | Whenever you call the `Publish` function the event is stored in a database table created only if not exists on the creation of the `Publisher`. This function takes a transaction, which makes your entities + creation of the event atomic.
11 |
12 | There is a go routine that every certain duration will try to fetch the outbox events pending for publishing if any. Each one of them will be published in the same way that the bunnify Publisher does. All published events will be marked as published in the database table (or deleted if configured).
13 |
14 | ## Installation
15 |
16 | `go get github.com/pmorelli92/bunnify/outbox`
17 |
18 | ## Examples
19 |
20 | **Setup Publisher**
21 |
22 | https://github.com/pmorelli92/bunnify/blob/985913450b7b9a21219b96f23843288bef7eac74/outbox/tests/consumer_publish_test.go#L87-L104
23 |
24 | **Publish**
25 |
26 | https://github.com/pmorelli92/bunnify/blob/985913450b7b9a21219b96f23843288bef7eac74/outbox/tests/consumer_publish_test.go#L107-L127
27 |
28 | **Configuration**
29 |
30 | https://github.com/pmorelli92/bunnify/blob/985913450b7b9a21219b96f23843288bef7eac74/outbox/publisherOption.go#L17-L38
31 |
--------------------------------------------------------------------------------
/outbox/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/pmorelli92/bunnify/outbox
2 |
3 | go 1.22.0
4 |
5 | toolchain go1.23.4
6 |
7 | replace github.com/pmorelli92/bunnify => ../
8 |
9 | require (
10 | github.com/google/uuid v1.6.0
11 | github.com/jackc/pgx/v5 v5.7.2
12 | github.com/pmorelli92/bunnify v0.0.8
13 | github.com/prometheus/client_golang v1.20.5
14 | go.opentelemetry.io/otel v1.34.0
15 | go.opentelemetry.io/otel/sdk v1.34.0
16 | go.opentelemetry.io/otel/trace v1.34.0
17 | go.uber.org/goleak v1.3.0
18 | )
19 |
20 | require (
21 | github.com/beorn7/perks v1.0.1 // indirect
22 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
23 | github.com/go-logr/logr v1.4.2 // indirect
24 | github.com/go-logr/stdr v1.2.2 // indirect
25 | github.com/jackc/pgpassfile v1.0.0 // indirect
26 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
27 | github.com/jackc/puddle/v2 v2.2.2 // indirect
28 | github.com/kylelemons/godebug v1.1.0 // indirect
29 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
30 | github.com/prometheus/client_model v0.6.1 // indirect
31 | github.com/prometheus/common v0.62.0 // indirect
32 | github.com/prometheus/procfs v0.15.1 // indirect
33 | github.com/rabbitmq/amqp091-go v1.10.0 // indirect
34 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect
35 | go.opentelemetry.io/otel/metric v1.34.0 // indirect
36 | golang.org/x/crypto v0.32.0 // indirect
37 | golang.org/x/sync v0.10.0 // indirect
38 | golang.org/x/sys v0.29.0 // indirect
39 | golang.org/x/text v0.21.0 // indirect
40 | google.golang.org/protobuf v1.36.4 // indirect
41 | )
42 |
--------------------------------------------------------------------------------
/outbox/go.sum:
--------------------------------------------------------------------------------
1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
9 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
10 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
11 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
12 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
13 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
15 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
16 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
17 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
18 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
19 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
20 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
21 | github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
22 | github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
23 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
24 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
25 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
26 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
27 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
28 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
29 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
30 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
33 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
34 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
35 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
36 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
37 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
38 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
39 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
40 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
41 | github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
42 | github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
43 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
44 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
45 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
46 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
47 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
48 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
49 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
50 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
51 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
52 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
53 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
54 | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
55 | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
56 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
57 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
58 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
59 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
60 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
61 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
62 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
63 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
64 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
65 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
66 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
67 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
68 | google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
69 | google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
71 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
74 |
--------------------------------------------------------------------------------
/outbox/internal/sqlc/db.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package sqlc
6 |
7 | import (
8 | "context"
9 |
10 | "github.com/jackc/pgx/v5"
11 | "github.com/jackc/pgx/v5/pgconn"
12 | )
13 |
14 | type DBTX interface {
15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error)
17 | QueryRow(context.Context, string, ...interface{}) pgx.Row
18 | }
19 |
20 | func New(db DBTX) *Queries {
21 | return &Queries{db: db}
22 | }
23 |
24 | type Queries struct {
25 | db DBTX
26 | }
27 |
28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries {
29 | return &Queries{
30 | db: tx,
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/outbox/internal/sqlc/models.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 |
5 | package sqlc
6 |
7 | import (
8 | "github.com/jackc/pgx/v5/pgtype"
9 | )
10 |
11 | type OutboxEvent struct {
12 | EventID string
13 | Exchange string
14 | RoutingKey string
15 | Payload []byte
16 | TraceID string
17 | SpanID string
18 | CreatedAt pgtype.Timestamptz
19 | Published bool
20 | }
21 |
--------------------------------------------------------------------------------
/outbox/internal/sqlc/queries.sql.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.27.0
4 | // source: queries.sql
5 |
6 | package sqlc
7 |
8 | import (
9 | "context"
10 |
11 | "github.com/jackc/pgx/v5/pgtype"
12 | )
13 |
14 | const deleteOutboxEvents = `-- name: DeleteOutboxEvents :exec
15 | DELETE FROM outbox_events WHERE event_id = ANY($1::text[])
16 | `
17 |
18 | func (q *Queries) DeleteOutboxEvents(ctx context.Context, ids []string) error {
19 | _, err := q.db.Exec(ctx, deleteOutboxEvents, ids)
20 | return err
21 | }
22 |
23 | const getOutboxEventsForPublish = `-- name: GetOutboxEventsForPublish :many
24 | SELECT event_id, exchange, routing_key, payload, trace_id, span_id, created_at, published FROM outbox_events
25 | WHERE published IS FALSE
26 | ORDER BY created_at ASC
27 | LIMIT 200 FOR UPDATE
28 | `
29 |
30 | func (q *Queries) GetOutboxEventsForPublish(ctx context.Context) ([]OutboxEvent, error) {
31 | rows, err := q.db.Query(ctx, getOutboxEventsForPublish)
32 | if err != nil {
33 | return nil, err
34 | }
35 | defer rows.Close()
36 | var items []OutboxEvent
37 | for rows.Next() {
38 | var i OutboxEvent
39 | if err := rows.Scan(
40 | &i.EventID,
41 | &i.Exchange,
42 | &i.RoutingKey,
43 | &i.Payload,
44 | &i.TraceID,
45 | &i.SpanID,
46 | &i.CreatedAt,
47 | &i.Published,
48 | ); err != nil {
49 | return nil, err
50 | }
51 | items = append(items, i)
52 | }
53 | if err := rows.Err(); err != nil {
54 | return nil, err
55 | }
56 | return items, nil
57 | }
58 |
59 | const insertOutboxEvents = `-- name: InsertOutboxEvents :exec
60 | INSERT INTO outbox_events(event_id, exchange, routing_key, payload, trace_id, span_id, created_at)
61 | VALUES ($1, $2, $3, $4, $5, $6, $7)
62 | `
63 |
64 | type InsertOutboxEventsParams struct {
65 | EventID string
66 | Exchange string
67 | RoutingKey string
68 | Payload []byte
69 | TraceID string
70 | SpanID string
71 | CreatedAt pgtype.Timestamptz
72 | }
73 |
74 | func (q *Queries) InsertOutboxEvents(ctx context.Context, arg InsertOutboxEventsParams) error {
75 | _, err := q.db.Exec(ctx, insertOutboxEvents,
76 | arg.EventID,
77 | arg.Exchange,
78 | arg.RoutingKey,
79 | arg.Payload,
80 | arg.TraceID,
81 | arg.SpanID,
82 | arg.CreatedAt,
83 | )
84 | return err
85 | }
86 |
87 | const markOutboxEventAsPublished = `-- name: MarkOutboxEventAsPublished :exec
88 | UPDATE outbox_events SET published = true
89 | WHERE event_id = ANY($1::text[])
90 | `
91 |
92 | func (q *Queries) MarkOutboxEventAsPublished(ctx context.Context, ids []string) error {
93 | _, err := q.db.Exec(ctx, markOutboxEventAsPublished, ids)
94 | return err
95 | }
96 |
--------------------------------------------------------------------------------
/outbox/notification.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/pmorelli92/bunnify"
7 | )
8 |
9 | func notifyEndingLoop(ch chan<- bunnify.Notification) {
10 | if ch != nil {
11 | ch <- bunnify.Notification{
12 | Type: bunnify.NotificationTypeInfo,
13 | Message: "the outbox loop has ended",
14 | Source: bunnify.NotificationSourcePublisher,
15 | }
16 | }
17 | }
18 |
19 | func notifyCannotCreateTx(ch chan<- bunnify.Notification) {
20 | if ch != nil {
21 | ch <- bunnify.Notification{
22 | Type: bunnify.NotificationTypeError,
23 | Message: "cannot create a transaction for outbox",
24 | Source: bunnify.NotificationSourcePublisher,
25 | }
26 | }
27 | }
28 |
29 | func notifyCannotQueryOutboxTable(ch chan<- bunnify.Notification) {
30 | if ch != nil {
31 | ch <- bunnify.Notification{
32 | Type: bunnify.NotificationTypeError,
33 | Message: "cannot query outbox table",
34 | Source: bunnify.NotificationSourcePublisher,
35 | }
36 | }
37 | }
38 |
39 | func notifyCannotPublishAMQP(ch chan<- bunnify.Notification) {
40 | if ch != nil {
41 | ch <- bunnify.Notification{
42 | Type: bunnify.NotificationTypeError,
43 | Message: "cannot publish the event to AMQP",
44 | Source: bunnify.NotificationSourcePublisher,
45 | }
46 | }
47 | }
48 |
49 | func notifyCannotMarkOutboxEventsAsPublished(ch chan<- bunnify.Notification) {
50 | if ch != nil {
51 | ch <- bunnify.Notification{
52 | Type: bunnify.NotificationTypeError,
53 | Message: "cannot mark outbox events as published",
54 | Source: bunnify.NotificationSourcePublisher,
55 | }
56 | }
57 | }
58 |
59 | func notifyCannotDeleteOutboxEvents(ch chan<- bunnify.Notification) {
60 | if ch != nil {
61 | ch <- bunnify.Notification{
62 | Type: bunnify.NotificationTypeError,
63 | Message: "cannot delete outbox events",
64 | Source: bunnify.NotificationSourcePublisher,
65 | }
66 | }
67 | }
68 |
69 | func notifyCannotCommitTransaction(ch chan<- bunnify.Notification) {
70 | if ch != nil {
71 | ch <- bunnify.Notification{
72 | Type: bunnify.NotificationTypeError,
73 | Message: "cannot commit transaction",
74 | Source: bunnify.NotificationSourcePublisher,
75 | }
76 | }
77 | }
78 |
79 | func notifyOutboxEventsPublished(ch chan<- bunnify.Notification, quantity int) {
80 | if ch != nil {
81 | ch <- bunnify.Notification{
82 | Type: bunnify.NotificationTypeInfo,
83 | Message: fmt.Sprintf("published %d event(s) correctly from outbox", quantity),
84 | Source: bunnify.NotificationSourcePublisher,
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/outbox/publisher.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "encoding/json"
7 | "fmt"
8 | "sync/atomic"
9 | "time"
10 |
11 | "github.com/jackc/pgx/v5"
12 | "github.com/jackc/pgx/v5/pgtype"
13 | "github.com/jackc/pgx/v5/pgxpool"
14 | "github.com/pmorelli92/bunnify"
15 | "github.com/pmorelli92/bunnify/outbox/internal/sqlc"
16 | "go.opentelemetry.io/otel/trace"
17 | )
18 |
19 | // Publisher is used for publishing events.
20 | type Publisher struct {
21 | db *pgxpool.Pool
22 | inner bunnify.Publisher
23 | options publisherOption
24 | closed *int32
25 | }
26 |
27 | //go:embed schema/schema.sql
28 | var outboxSchema string
29 |
30 | // NewPublisher creates a publisher using a database
31 | // connection and acts as a wrapper for bunnify publisher.
32 | func NewPublisher(
33 | ctx context.Context,
34 | db *pgxpool.Pool,
35 | inner bunnify.Publisher,
36 | opts ...func(*publisherOption)) (*Publisher, error) {
37 |
38 | options := publisherOption{
39 | deleteAfterPublish: false,
40 | loopInterval: 5 * time.Second,
41 | }
42 | for _, opt := range opts {
43 | opt(&options)
44 | }
45 |
46 | _, err := db.Exec(ctx, outboxSchema)
47 | if err != nil {
48 | return nil, err
49 | }
50 |
51 | p := &Publisher{
52 | db: db,
53 | inner: inner,
54 | options: options,
55 | closed: new(int32),
56 | }
57 |
58 | go p.loop()
59 | return p, nil
60 | }
61 |
62 | // Publish publishes an event to the outbox database table.
63 | // Then the loop will pick this up and try to use the inner bunnify Publisher do an AQMP publish.
64 | func (p *Publisher) Publish(
65 | ctx context.Context,
66 | tx pgx.Tx,
67 | exchange string,
68 | routingKey string,
69 | event bunnify.PublishableEvent) error {
70 |
71 | payload, err := json.Marshal(event)
72 | if err != nil {
73 | return fmt.Errorf("could not marshal event: %w", err)
74 | }
75 |
76 | // If telemetry is not used, this will be just default zeroes
77 | spanContext := trace.SpanFromContext(ctx).SpanContext()
78 |
79 | return sqlc.New(tx).InsertOutboxEvents(ctx, sqlc.InsertOutboxEventsParams{
80 | EventID: event.ID,
81 | Exchange: exchange,
82 | RoutingKey: routingKey,
83 | Payload: payload,
84 | TraceID: spanContext.TraceID().String(),
85 | SpanID: spanContext.SpanID().String(),
86 | CreatedAt: pgtype.Timestamptz{Valid: true, Time: event.Timestamp},
87 | })
88 | }
89 |
90 | func (p *Publisher) Close() {
91 | atomic.StoreInt32(p.closed, 1)
92 | }
93 |
--------------------------------------------------------------------------------
/outbox/publisherLoop.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "encoding/json"
7 | "sync/atomic"
8 | "time"
9 |
10 | "github.com/pmorelli92/bunnify"
11 | "github.com/pmorelli92/bunnify/outbox/internal/sqlc"
12 | "go.opentelemetry.io/otel/trace"
13 | )
14 |
15 | // loop every certain duration to check if there are
16 | // events to be published with the inner bunnify publisher to AMQP.
17 | func (p *Publisher) loop() {
18 | ticker := time.NewTicker(p.options.loopInterval)
19 | for {
20 | if atomic.LoadInt32(p.closed) == 1 {
21 | notifyEndingLoop(p.options.notificationChannel)
22 | return
23 | }
24 |
25 | <-ticker.C
26 |
27 | ctx := context.TODO()
28 | tx, err := p.db.Begin(ctx)
29 | if err != nil {
30 | notifyCannotCreateTx(p.options.notificationChannel)
31 | continue
32 | }
33 |
34 | q := sqlc.New(tx)
35 | evts, err := q.GetOutboxEventsForPublish(ctx)
36 | if err != nil {
37 | _ = tx.Rollback(ctx)
38 | notifyCannotQueryOutboxTable(p.options.notificationChannel)
39 | continue
40 | }
41 |
42 | if len(evts) == 0 {
43 | _ = tx.Rollback(ctx)
44 | continue
45 | }
46 |
47 | ids := make([]string, 0, len(evts))
48 | for _, e := range evts {
49 | var publishableEvt bunnify.PublishableEvent
50 | err = json.Unmarshal(e.Payload, &publishableEvt)
51 | if err != nil {
52 | continue
53 | }
54 |
55 | // Recreates a context with trace id and span id stored in the database, if any
56 | sid, _ := trace.SpanIDFromHex(e.SpanID)
57 | tid, _ := trace.TraceIDFromHex(e.TraceID)
58 | newSpanContext := trace.NewSpanContext(trace.SpanContextConfig{
59 | SpanID: sid,
60 | TraceID: tid,
61 | })
62 |
63 | traceCtx := trace.ContextWithSpanContext(context.TODO(), newSpanContext)
64 | err = p.inner.Publish(traceCtx, e.Exchange, e.RoutingKey, publishableEvt)
65 | if err != nil {
66 | notifyCannotPublishAMQP(p.options.notificationChannel)
67 | continue
68 | }
69 |
70 | ids = append(ids, e.EventID)
71 | }
72 |
73 | if p.options.deleteAfterPublish {
74 | if err := q.DeleteOutboxEvents(ctx, ids); err != nil {
75 | notifyCannotDeleteOutboxEvents(p.options.notificationChannel)
76 | continue
77 | }
78 | } else {
79 | if err := q.MarkOutboxEventAsPublished(ctx, ids); err != nil {
80 | notifyCannotMarkOutboxEventsAsPublished(p.options.notificationChannel)
81 | continue
82 | }
83 | }
84 |
85 | if err = tx.Commit(ctx); err != nil {
86 | notifyCannotCommitTransaction(p.options.notificationChannel)
87 | _ = tx.Rollback(ctx)
88 | continue
89 | }
90 |
91 | notifyOutboxEventsPublished(p.options.notificationChannel, len(ids))
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/outbox/publisherOption.go:
--------------------------------------------------------------------------------
1 | package outbox
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/pmorelli92/bunnify"
7 | )
8 |
9 | type publisherOption struct {
10 | deleteAfterPublish bool
11 | loopInterval time.Duration
12 | notificationChannel chan<- bunnify.Notification
13 | }
14 |
15 | // WithDeleteAfterPublish specifies that a published event from
16 | // the outbox will be deleted instead of marked as published
17 | // as it is not interesting to have the event as a timelog history.
18 | func WithDeleteAfterPublish() func(*publisherOption) {
19 | return func(opt *publisherOption) {
20 | opt.deleteAfterPublish = true
21 | }
22 | }
23 |
24 | // WithLoopingInterval specifies the interval on which the
25 | // loop to check the pending to publish events is executed
26 | func WithLoopingInterval(interval time.Duration) func(*publisherOption) {
27 | return func(opt *publisherOption) {
28 | opt.loopInterval = interval
29 | }
30 | }
31 |
32 | // WithNoficationChannel specifies a go channel to receive messages
33 | // such as connection established, reconnecting, event published, consumed, etc.
34 | func WithNoficationChannel(notificationCh chan<- bunnify.Notification) func(*publisherOption) {
35 | return func(opt *publisherOption) {
36 | opt.notificationChannel = notificationCh
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/outbox/queries/queries.sql:
--------------------------------------------------------------------------------
1 | -- name: InsertOutboxEvents :exec
2 | INSERT INTO outbox_events(event_id, exchange, routing_key, payload, trace_id, span_id, created_at)
3 | VALUES ($1, $2, $3, $4, $5, $6, $7);
4 |
5 | -- name: MarkOutboxEventAsPublished :exec
6 | UPDATE outbox_events SET published = true
7 | WHERE event_id = ANY(@ids::text[]);
8 |
9 | -- name: DeleteOutboxEvents :exec
10 | DELETE FROM outbox_events WHERE event_id = ANY(@ids::text[]);
11 |
12 | -- name: GetOutboxEventsForPublish :many
13 | SELECT * FROM outbox_events
14 | WHERE published IS FALSE
15 | ORDER BY created_at ASC
16 | LIMIT 200 FOR UPDATE;
17 |
--------------------------------------------------------------------------------
/outbox/schema/schema.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS outbox_events(
2 | event_id VARCHAR NOT NULL PRIMARY KEY,
3 | exchange VARCHAR NOT NULL,
4 | routing_key VARCHAR NOT NULL,
5 | payload JSONB NOT NULL,
6 | trace_id VARCHAR NOT NULL,
7 | span_id VARCHAR NOT NULL,
8 | created_at TIMESTAMP WITH TIME ZONE NOT NULL,
9 | published BOOLEAN NOT NULL DEFAULT FALSE);
10 |
11 | CREATE INDEX IF NOT EXISTS ix_outbox_event_publish ON outbox_events(published);
12 |
--------------------------------------------------------------------------------
/outbox/sqlc.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | sql:
3 | - engine: "postgresql"
4 | queries: "queries"
5 | schema: "schema"
6 | gen:
7 | go:
8 | package: "sqlc"
9 | out: internal/sqlc
10 | sql_package: "pgx/v5"
11 |
--------------------------------------------------------------------------------
/outbox/tests/consumer_publish_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "sync"
8 | "testing"
9 | "time"
10 |
11 | "github.com/google/uuid"
12 | "github.com/jackc/pgx/v5/pgxpool"
13 | "github.com/pmorelli92/bunnify"
14 | "github.com/pmorelli92/bunnify/outbox"
15 | "github.com/prometheus/client_golang/prometheus"
16 | "github.com/prometheus/client_golang/prometheus/testutil"
17 | "go.opentelemetry.io/otel"
18 | "go.opentelemetry.io/otel/propagation"
19 | tracesdk "go.opentelemetry.io/otel/sdk/trace"
20 | "go.opentelemetry.io/otel/trace"
21 | "go.uber.org/goleak"
22 | )
23 |
24 | func TestOutboxWithAllAddsOn(t *testing.T) {
25 | // Setup tracing
26 | otel.SetTracerProvider(tracesdk.NewTracerProvider())
27 | otel.SetTextMapPropagator(propagation.TraceContext{})
28 |
29 | // Setup notification channel
30 | exitCh := make(chan bool)
31 | notificationChannel := make(chan bunnify.Notification)
32 | go func() {
33 | for {
34 | select {
35 | case n := <-notificationChannel:
36 | fmt.Println(n)
37 | case <-exitCh:
38 | return
39 | }
40 | }
41 | }()
42 |
43 | // Setup prometheus
44 | r := prometheus.NewRegistry()
45 | err := bunnify.InitMetrics(r)
46 | if err != nil {
47 | t.Fatal(err)
48 | }
49 |
50 | // Setup connection
51 | connection := bunnify.NewConnection(bunnify.WithNotificationChannel(notificationChannel))
52 | if err := connection.Start(); err != nil {
53 | t.Fatal(err)
54 | }
55 |
56 | // Setup consumer parameters
57 | queueName := uuid.NewString()
58 | exchangeName := uuid.NewString()
59 | routingKey := "order.orderCreated"
60 |
61 | type orderCreated struct {
62 | ID string `json:"id"`
63 | }
64 |
65 | // Populate variables in the closure when the consumer handler
66 | // is executed after event is published and the consumer is triggered
67 | var actualSpanID trace.SpanID
68 | var actualTraceID trace.TraceID
69 | var consumedEvent bunnify.ConsumableEvent[orderCreated]
70 |
71 | // Signal when event was consumed
72 | wg := sync.WaitGroup{}
73 | wg.Add(1)
74 |
75 | eventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
76 | consumedEvent = event
77 | actualSpanID = trace.SpanFromContext(ctx).SpanContext().SpanID()
78 | actualTraceID = trace.SpanFromContext(ctx).SpanContext().TraceID()
79 | wg.Done()
80 | return nil
81 | }
82 |
83 | // Setup consumer
84 | consumer := connection.NewConsumer(
85 | queueName,
86 | bunnify.WithQuorumQueue(),
87 | bunnify.WithBindingToExchange(exchangeName),
88 | bunnify.WithHandler(routingKey, eventHandler))
89 |
90 | if err := consumer.Consume(); err != nil {
91 | t.Fatal(err)
92 | }
93 |
94 | // Setup publisher
95 | publisher := connection.NewPublisher()
96 |
97 | // Setup database connection
98 | dbCtx := context.TODO()
99 | db, err := pgxpool.New(dbCtx, "postgresql://db:pass@localhost:5432/db")
100 | if err != nil {
101 | t.Fatal(err)
102 | }
103 |
104 | // Setup outbox publisher
105 | outboxPublisher, err := outbox.NewPublisher(
106 | dbCtx, db, *publisher,
107 | outbox.WithLoopingInterval(500*time.Millisecond),
108 | outbox.WithNoficationChannel(notificationChannel))
109 | if err != nil {
110 | t.Fatal(err)
111 | }
112 |
113 | // Exercise
114 | orderCreatedID := uuid.NewString()
115 | eventToPublish := bunnify.NewPublishableEvent(orderCreated{ID: orderCreatedID})
116 | publisherCtx, _ := otel.Tracer("amqp").Start(context.Background(), "outbox-publisher")
117 |
118 | tx, err := db.Begin(dbCtx)
119 | if err != nil {
120 | t.Fatal(err)
121 | }
122 |
123 | defer func() {
124 | _ = tx.Rollback(dbCtx)
125 | }()
126 |
127 | err = outboxPublisher.Publish(publisherCtx, tx, exchangeName, routingKey, eventToPublish)
128 | if err != nil {
129 | t.Fatal(err)
130 | }
131 |
132 | if err := tx.Commit(dbCtx); err != nil {
133 | t.Fatal(err)
134 | }
135 |
136 | // Wait for event to be consumed
137 | wg.Wait()
138 |
139 | // Assert tracing data
140 | expectedSpanID := trace.SpanFromContext(publisherCtx).SpanContext().SpanID()
141 | if actualSpanID != expectedSpanID {
142 | t.Fatalf("expected spanID %s, got %s", expectedSpanID, actualSpanID)
143 | }
144 | expectedTraceID := trace.SpanFromContext(publisherCtx).SpanContext().TraceID()
145 | if actualTraceID != expectedTraceID {
146 | t.Fatalf("expected traceID %s, got %s", expectedTraceID, actualTraceID)
147 | }
148 |
149 | // Assert event data
150 | if orderCreatedID != consumedEvent.Payload.ID {
151 | t.Fatalf("expected order created ID %s, got %s", orderCreatedID, consumedEvent.Payload.ID)
152 | }
153 |
154 | // Assert event metadata
155 | if eventToPublish.ID != consumedEvent.ID {
156 | t.Fatalf("expected event ID %s, got %s", eventToPublish.ID, consumedEvent.ID)
157 | }
158 | if eventToPublish.CorrelationID != consumedEvent.CorrelationID {
159 | t.Fatalf("expected correlation ID %s, got %s", eventToPublish.CorrelationID, consumedEvent.CorrelationID)
160 | }
161 | if !eventToPublish.Timestamp.Equal(consumedEvent.Timestamp) {
162 | t.Fatalf("expected timestamp %s, got %s", eventToPublish.Timestamp, consumedEvent.Timestamp)
163 | }
164 | if exchangeName != consumedEvent.DeliveryInfo.Exchange {
165 | t.Fatalf("expected exchange %s, got %s", exchangeName, consumedEvent.DeliveryInfo.Exchange)
166 | }
167 | if queueName != consumedEvent.DeliveryInfo.Queue {
168 | t.Fatalf("expected queue %s, got %s", queueName, consumedEvent.DeliveryInfo.Queue)
169 | }
170 | if routingKey != consumedEvent.DeliveryInfo.RoutingKey {
171 | t.Fatalf("expected routing key %s, got %s", routingKey, consumedEvent.DeliveryInfo.RoutingKey)
172 | }
173 |
174 | // Assert prometheus metrics
175 | if err = assertMetrics(r,
176 | "amqp_events_publish_succeed",
177 | "amqp_events_received",
178 | "amqp_events_ack",
179 | "amqp_events_processed_duration"); err != nil {
180 | t.Fatal(err)
181 | }
182 |
183 | // Dispose outbox
184 | outboxPublisher.Close()
185 | db.Close()
186 |
187 | // Dispose amqp connection
188 | if err := connection.Close(); err != nil {
189 | t.Fatal(err)
190 | }
191 |
192 | // Wait for the consumer and outbox loop
193 | time.Sleep(1000 * time.Millisecond)
194 |
195 | // Stop the notification go routine so goleak does not fail
196 | exitCh <- true
197 |
198 | // Assert no routine is running at the end
199 | goleak.VerifyNone(t)
200 | }
201 |
202 | func assertMetrics(
203 | prometheusGatherer prometheus.Gatherer,
204 | metrics ...string) error {
205 |
206 | errs := make([]error, 0)
207 | for _, m := range metrics {
208 | actualQuantity, err := testutil.GatherAndCount(prometheusGatherer, m)
209 | if err != nil {
210 | return err
211 | }
212 |
213 | if actualQuantity != 1 {
214 | errs = append(errs, fmt.Errorf("expected %s quantity 1, received %d", m, actualQuantity))
215 | }
216 | }
217 |
218 | return errors.Join(errs...)
219 | }
220 |
--------------------------------------------------------------------------------
/outbox/tests/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | postgres:
3 | image: "postgres"
4 | ports:
5 | - "5432:5432"
6 | environment:
7 | - POSTGRES_DB=db
8 | - POSTGRES_USER=db
9 | - POSTGRES_PASSWORD=pass
10 |
--------------------------------------------------------------------------------
/publishableEvent.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | )
8 |
9 | // PublishableEvent represents an event that can be published.
10 | // The Payload field holds the event's payload data, which can be of
11 | // any type that can be marshal to json.
12 | type PublishableEvent struct {
13 | Metadata
14 | Payload any `json:"payload"`
15 | }
16 |
17 | type eventOptions struct {
18 | eventID string
19 | correlationID string
20 | }
21 |
22 | // WithEventID specifies the eventID to be published
23 | // if it is not used a random uuid will be generated.
24 | func WithEventID(eventID string) func(*eventOptions) {
25 | return func(opt *eventOptions) {
26 | opt.eventID = eventID
27 | }
28 | }
29 |
30 | // WithCorrelationID specifies the correlationID to be published
31 | // if it is not used a random uuid will be generated.
32 | func WithCorrelationID(correlationID string) func(*eventOptions) {
33 | return func(opt *eventOptions) {
34 | opt.correlationID = correlationID
35 | }
36 | }
37 |
38 | // NewPublishableEvent creates an instance of a PublishableEvent.
39 | // In case the ID and correlation ID are not supplied via options random uuid will be generated.
40 | func NewPublishableEvent(payload any, opts ...func(*eventOptions)) PublishableEvent {
41 | evtOpts := eventOptions{}
42 | for _, opt := range opts {
43 | opt(&evtOpts)
44 | }
45 |
46 | if evtOpts.correlationID == "" {
47 | evtOpts.correlationID = uuid.NewString()
48 | }
49 | if evtOpts.eventID == "" {
50 | evtOpts.eventID = uuid.NewString()
51 | }
52 |
53 | return PublishableEvent{
54 | Metadata: Metadata{
55 | ID: evtOpts.eventID,
56 | CorrelationID: evtOpts.correlationID,
57 | Timestamp: time.Now(),
58 | },
59 | Payload: payload,
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/publisher.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | amqp "github.com/rabbitmq/amqp091-go"
9 | )
10 |
11 | // Publisher is used for publishing events.
12 | type Publisher struct {
13 | inUseChannel *amqp.Channel
14 | getNewChannel func() (*amqp.Channel, bool)
15 | }
16 |
17 | // NewPublisher creates a publisher using the specified connection.
18 | func (c *Connection) NewPublisher() *Publisher {
19 | return &Publisher{
20 | getNewChannel: func() (*amqp.Channel, bool) {
21 | return c.getNewChannel(NotificationSourcePublisher)
22 | },
23 | }
24 | }
25 |
26 | // Publish publishes an event to the specified exchange.
27 | // If the channel is closed, it will retry until a channel is obtained.
28 | func (p *Publisher) Publish(
29 | ctx context.Context,
30 | exchange, routingKey string,
31 | event PublishableEvent) error {
32 |
33 | if p.inUseChannel == nil || p.inUseChannel.IsClosed() {
34 | channel, connectionClosed := p.getNewChannel()
35 | if connectionClosed {
36 | return fmt.Errorf("connection closed by system, channel will not reconnect")
37 | }
38 | p.inUseChannel = channel
39 | }
40 |
41 | b, err := json.Marshal(event)
42 | if err != nil {
43 | return fmt.Errorf("could not marshal event: %w", err)
44 | }
45 |
46 | publishing := amqp.Publishing{
47 | ContentEncoding: "application/json",
48 | CorrelationId: event.CorrelationID,
49 | MessageId: event.ID,
50 | Timestamp: event.Timestamp,
51 | Body: b,
52 | Headers: injectToHeaders(ctx),
53 | }
54 |
55 | err = p.inUseChannel.PublishWithContext(ctx, exchange, routingKey, true, false, publishing)
56 | if err != nil {
57 | eventPublishFailed(exchange, routingKey)
58 | return err
59 | }
60 |
61 | eventPublishSucceed(exchange, routingKey)
62 | return nil
63 | }
64 |
--------------------------------------------------------------------------------
/tests/consumer_invalid_options_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/pmorelli92/bunnify"
7 | "go.uber.org/goleak"
8 | )
9 |
10 | func TestConnectionReturnErrorWhenNotValidURI(t *testing.T) {
11 | // Setup
12 | connection := bunnify.NewConnection(bunnify.WithURI("13123"))
13 |
14 | // Exercise
15 | err := connection.Start()
16 |
17 | // Assert
18 | if err == nil {
19 | t.Fatal(err)
20 | }
21 |
22 | goleak.VerifyNone(t)
23 | }
24 |
25 | func TestConsumerShouldReturnErrorWhenNoHandlersSpecified(t *testing.T) {
26 | // Setup
27 | connection := bunnify.NewConnection()
28 | if err := connection.Start(); err != nil {
29 | t.Fatal(err)
30 | }
31 |
32 | // Exercise
33 | consumer := connection.NewConsumer("queueName")
34 | err := consumer.Consume()
35 |
36 | // Assert
37 | if err == nil {
38 | t.Fatal(err)
39 | }
40 |
41 | if err := connection.Close(); err != nil {
42 | t.Fatal(err)
43 | }
44 |
45 | goleak.VerifyNone(t)
46 | }
47 |
--------------------------------------------------------------------------------
/tests/consumer_publish_metrics_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "testing"
8 | "time"
9 |
10 | "github.com/google/uuid"
11 | "github.com/pmorelli92/bunnify"
12 | "github.com/prometheus/client_golang/prometheus"
13 | "github.com/prometheus/client_golang/prometheus/testutil"
14 | "go.uber.org/goleak"
15 | )
16 |
17 | func TestConsumerPublisherMetrics(t *testing.T) {
18 | t.Run("ACK event", func(t *testing.T) {
19 | connection := bunnify.NewConnection()
20 | if err := connection.Start(); err != nil {
21 | t.Fatal(err)
22 | }
23 |
24 | publisher := connection.NewPublisher()
25 |
26 | queueName := uuid.NewString()
27 | exchangeName := uuid.NewString()
28 | routingKey := uuid.NewString()
29 |
30 | r := prometheus.NewRegistry()
31 | err := bunnify.InitMetrics(r)
32 | if err != nil {
33 | t.Fatal(err)
34 | }
35 |
36 | // Multiple invocations should not fail
37 | err = bunnify.InitMetrics(r)
38 | if err != nil {
39 | t.Fatal()
40 | }
41 |
42 | // Exercise consuming
43 | eventHandler := func(ctx context.Context, _ bunnify.ConsumableEvent[any]) error {
44 | return nil
45 | }
46 |
47 | consumer := connection.NewConsumer(
48 | queueName,
49 | bunnify.WithBindingToExchange(exchangeName),
50 | bunnify.WithHandler(routingKey, eventHandler))
51 | if err := consumer.Consume(); err != nil {
52 | t.Fatal(err)
53 | }
54 |
55 | err = publisher.Publish(
56 | context.Background(),
57 | exchangeName,
58 | routingKey,
59 | bunnify.NewPublishableEvent(struct{}{}))
60 | if err != nil {
61 | t.Fatal(err)
62 | }
63 |
64 | time.Sleep(50 * time.Millisecond)
65 |
66 | if err := connection.Close(); err != nil {
67 | t.Fatal(err)
68 | }
69 |
70 | if err = assertMetrics(r,
71 | "amqp_events_publish_succeed",
72 | "amqp_events_received",
73 | "amqp_events_ack",
74 | "amqp_events_processed_duration"); err != nil {
75 | t.Fatal(err)
76 | }
77 | })
78 |
79 | t.Run("NACK event", func(t *testing.T) {
80 | connection := bunnify.NewConnection()
81 | if err := connection.Start(); err != nil {
82 | t.Fatal(err)
83 | }
84 |
85 | publisher := connection.NewPublisher()
86 |
87 | queueName := uuid.NewString()
88 | exchangeName := uuid.NewString()
89 | routingKey := uuid.NewString()
90 |
91 | r := prometheus.NewRegistry()
92 | err := bunnify.InitMetrics(r)
93 | if err != nil {
94 | t.Fatal(err)
95 | }
96 |
97 | // Exercise consuming
98 | eventHandler := func(ctx context.Context, _ bunnify.ConsumableEvent[any]) error {
99 | return fmt.Errorf("error here")
100 | }
101 |
102 | consumer := connection.NewConsumer(
103 | queueName,
104 | bunnify.WithBindingToExchange(exchangeName),
105 | bunnify.WithHandler(routingKey, eventHandler))
106 | if err := consumer.Consume(); err != nil {
107 | t.Fatal(err)
108 | }
109 |
110 | err = publisher.Publish(
111 | context.Background(),
112 | exchangeName,
113 | routingKey,
114 | bunnify.NewPublishableEvent(struct{}{}))
115 | if err != nil {
116 | t.Fatal(err)
117 | }
118 |
119 | time.Sleep(50 * time.Millisecond)
120 |
121 | if err := connection.Close(); err != nil {
122 | t.Fatal(err)
123 | }
124 |
125 | if err = assertMetrics(r,
126 | "amqp_events_publish_succeed",
127 | "amqp_events_received",
128 | "amqp_events_nack",
129 | "amqp_events_processed_duration"); err != nil {
130 | t.Fatal(err)
131 | }
132 | })
133 |
134 | t.Run("No handler for the event", func(t *testing.T) {
135 | r := prometheus.NewRegistry()
136 | err := bunnify.InitMetrics(r)
137 | if err != nil {
138 | t.Fatal(err)
139 | }
140 |
141 | connection := bunnify.NewConnection()
142 | if err := connection.Start(); err != nil {
143 | t.Fatal(err)
144 | }
145 |
146 | queueName := uuid.NewString()
147 | exchangeName := uuid.NewString()
148 | routingKey := uuid.NewString()
149 |
150 | // Exercise consuming
151 | eventHandler := func(ctx context.Context, _ bunnify.ConsumableEvent[any]) error {
152 | return nil
153 | }
154 |
155 | consumer := connection.NewConsumer(
156 | queueName,
157 | bunnify.WithBindingToExchange(exchangeName),
158 | bunnify.WithHandler(routingKey, eventHandler))
159 | if err := consumer.Consume(); err != nil {
160 | t.Fatal(err)
161 | }
162 |
163 | if err := connection.Close(); err != nil {
164 | t.Fatal(err)
165 | }
166 |
167 | connection = bunnify.NewConnection()
168 | if err := connection.Start(); err != nil {
169 | t.Fatal(err)
170 | }
171 |
172 | // Register again but with other routing key
173 | // The existing binding on the AMQP instance still exists
174 | otherConsumer := connection.NewConsumer(
175 | queueName,
176 | bunnify.WithBindingToExchange(exchangeName),
177 | bunnify.WithHandler("not-used-key", eventHandler))
178 | if err := otherConsumer.Consume(); err != nil {
179 | t.Fatal(err)
180 | }
181 |
182 | publisher := connection.NewPublisher()
183 |
184 | err = publisher.Publish(
185 | context.Background(),
186 | exchangeName,
187 | routingKey,
188 | bunnify.NewPublishableEvent(struct{}{}))
189 | if err != nil {
190 | t.Fatal(err)
191 | }
192 |
193 | time.Sleep(50 * time.Millisecond)
194 |
195 | if err := connection.Close(); err != nil {
196 | t.Fatal(err)
197 | }
198 |
199 | if err = assertMetrics(r,
200 | "amqp_events_publish_succeed",
201 | "amqp_events_received",
202 | "amqp_events_without_handler"); err != nil {
203 | t.Fatal(err)
204 | }
205 | })
206 |
207 | goleak.VerifyNone(t)
208 | }
209 |
210 | func assertMetrics(
211 | prometheusGatherer prometheus.Gatherer,
212 | metrics ...string) error {
213 |
214 | errs := make([]error, 0)
215 | for _, m := range metrics {
216 | actualQuantity, err := testutil.GatherAndCount(prometheusGatherer, m)
217 | if err != nil {
218 | return err
219 | }
220 |
221 | if actualQuantity != 1 {
222 | errs = append(errs, fmt.Errorf("expected %s quantity 1, received %d", m, actualQuantity))
223 | }
224 | }
225 |
226 | return errors.Join(errs...)
227 | }
228 |
--------------------------------------------------------------------------------
/tests/consumer_publish_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "testing"
8 | "time"
9 |
10 | "github.com/google/uuid"
11 | "github.com/pmorelli92/bunnify"
12 | "go.uber.org/goleak"
13 | )
14 |
15 | func TestConsumerPublisher(t *testing.T) {
16 | // Setup
17 | queueName := uuid.NewString()
18 | exchangeName := uuid.NewString()
19 | routingKey := "order.orderCreated"
20 |
21 | type orderCreated struct {
22 | ID string `json:"id"`
23 | }
24 |
25 | exitCh := make(chan bool)
26 | notificationChannel := make(chan bunnify.Notification)
27 | go func() {
28 | for {
29 | select {
30 | case n := <-notificationChannel:
31 | fmt.Println(n)
32 | case <-exitCh:
33 | return
34 | }
35 | }
36 | }()
37 |
38 | // Exercise
39 | connection := bunnify.NewConnection(
40 | bunnify.WithURI("amqp://localhost:5672"),
41 | bunnify.WithReconnectInterval(1*time.Second),
42 | bunnify.WithNotificationChannel(notificationChannel))
43 |
44 | if err := connection.Start(); err != nil {
45 | t.Fatal(err)
46 | }
47 |
48 | var consumedEvent bunnify.ConsumableEvent[orderCreated]
49 | eventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
50 | consumedEvent = event
51 | return nil
52 | }
53 |
54 | consumer := connection.NewConsumer(
55 | queueName,
56 | bunnify.WithQuorumQueue(),
57 | bunnify.WithBindingToExchange(exchangeName))
58 |
59 | bunnify.AddHandlerToConsumer(&consumer, routingKey, eventHandler)
60 |
61 | if err := consumer.Consume(); err != nil {
62 | t.Fatal(err)
63 | }
64 |
65 | publisher := connection.NewPublisher()
66 |
67 | orderCreatedID := uuid.NewString()
68 | eventToPublish := bunnify.NewPublishableEvent(orderCreated{
69 | ID: orderCreatedID,
70 | })
71 |
72 | err := publisher.Publish(
73 | context.TODO(),
74 | exchangeName,
75 | routingKey,
76 | eventToPublish)
77 | if err != nil {
78 | t.Fatal(err)
79 | }
80 |
81 | time.Sleep(50 * time.Millisecond)
82 |
83 | if err := connection.Close(); err != nil {
84 | t.Fatal(err)
85 | }
86 |
87 | // Stop the notification go routine so goleak does not fail
88 | exitCh <- true
89 |
90 | // Assert
91 | if eventToPublish.ID != consumedEvent.ID {
92 | t.Fatalf("expected event ID %s, got %s", eventToPublish.ID, consumedEvent.ID)
93 | }
94 | if eventToPublish.CorrelationID != consumedEvent.CorrelationID {
95 | t.Fatalf("expected correlation ID %s, got %s", eventToPublish.CorrelationID, consumedEvent.CorrelationID)
96 | }
97 | if !eventToPublish.Timestamp.Equal(consumedEvent.Timestamp) {
98 | t.Fatalf("expected timestamp %s, got %s", eventToPublish.Timestamp, consumedEvent.Timestamp)
99 | }
100 | if orderCreatedID != consumedEvent.Payload.ID {
101 | t.Fatalf("expected order created ID %s, got %s", orderCreatedID, consumedEvent.Payload.ID)
102 | }
103 | if exchangeName != consumedEvent.DeliveryInfo.Exchange {
104 | t.Fatalf("expected exchange %s, got %s", exchangeName, consumedEvent.DeliveryInfo.Exchange)
105 | }
106 | if queueName != consumedEvent.DeliveryInfo.Queue {
107 | t.Fatalf("expected queue %s, got %s", queueName, consumedEvent.DeliveryInfo.Queue)
108 | }
109 | if routingKey != consumedEvent.DeliveryInfo.RoutingKey {
110 | t.Fatalf("expected routing key %s, got %s", routingKey, consumedEvent.DeliveryInfo.RoutingKey)
111 | }
112 |
113 | goleak.VerifyNone(t)
114 | }
115 |
116 | func TestConsumerDefaultHandler(t *testing.T) {
117 | // Setup
118 | queueName := uuid.NewString()
119 |
120 | type orderCreated struct {
121 | ID string `json:"id"`
122 | }
123 |
124 | type orderUpdated struct {
125 | ID string `json:"id"`
126 | UpdatedAt time.Time `json:"updatedAt"`
127 | }
128 |
129 | connection := bunnify.NewConnection()
130 | if err := connection.Start(); err != nil {
131 | t.Fatal(err)
132 | }
133 |
134 | var consumedEvents []bunnify.ConsumableEvent[json.RawMessage]
135 | eventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[json.RawMessage]) error {
136 | consumedEvents = append(consumedEvents, event)
137 | return nil
138 | }
139 |
140 | // Bind only to queue received messages
141 | consumer := connection.NewConsumer(
142 | queueName,
143 | bunnify.WithDefaultHandler(eventHandler))
144 |
145 | if err := consumer.Consume(); err != nil {
146 | t.Fatal(err)
147 | }
148 |
149 | orderCreatedEvent := orderCreated{ID: uuid.NewString()}
150 | orderUpdatedEvent := orderUpdated{ID: uuid.NewString(), UpdatedAt: time.Now()}
151 | publisher := connection.NewPublisher()
152 |
153 | // Publish directly to the queue, without routing key
154 | err := publisher.Publish(
155 | context.TODO(),
156 | "",
157 | queueName,
158 | bunnify.NewPublishableEvent(orderCreatedEvent))
159 | if err != nil {
160 | t.Fatal(err)
161 | }
162 |
163 | // Publish directly to the queue, without routing key
164 | err = publisher.Publish(
165 | context.TODO(),
166 | "",
167 | queueName,
168 | bunnify.NewPublishableEvent(orderUpdatedEvent))
169 | if err != nil {
170 | t.Fatal(err)
171 | }
172 |
173 | time.Sleep(50 * time.Millisecond)
174 |
175 | if err := connection.Close(); err != nil {
176 | t.Fatal(err)
177 | }
178 |
179 | // Assert
180 | if len(consumedEvents) != 2 {
181 | t.Fatalf("expected 2 events, got %d", len(consumedEvents))
182 | }
183 |
184 | // First event should be orderCreated
185 | var receivedOrderCreated orderCreated
186 | err = json.Unmarshal(consumedEvents[0].Payload, &receivedOrderCreated)
187 | if err != nil {
188 | t.Fatal(err)
189 | }
190 |
191 | if orderCreatedEvent.ID != receivedOrderCreated.ID {
192 | t.Fatalf("expected created order ID to be %s got %s", orderCreatedEvent.ID, receivedOrderCreated.ID)
193 | }
194 |
195 | var receivedOrderUpdated orderUpdated
196 | err = json.Unmarshal(consumedEvents[1].Payload, &receivedOrderUpdated)
197 | if err != nil {
198 | t.Fatal(err)
199 | }
200 |
201 | if orderUpdatedEvent.ID != receivedOrderUpdated.ID {
202 | t.Fatalf("expected updated order ID to be %s got %s", orderUpdatedEvent.ID, receivedOrderUpdated.ID)
203 | }
204 |
205 | if !orderUpdatedEvent.UpdatedAt.Equal(receivedOrderUpdated.UpdatedAt) {
206 | t.Fatalf("expected updated order time to be %s got %s", orderUpdatedEvent.UpdatedAt, receivedOrderUpdated.UpdatedAt)
207 | }
208 |
209 | goleak.VerifyNone(t)
210 | }
211 |
--------------------------------------------------------------------------------
/tests/consumer_publish_tracer_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/google/uuid"
9 | "github.com/pmorelli92/bunnify"
10 | "go.opentelemetry.io/otel"
11 | "go.opentelemetry.io/otel/propagation"
12 | tracesdk "go.opentelemetry.io/otel/sdk/trace"
13 | "go.opentelemetry.io/otel/trace"
14 | "go.uber.org/goleak"
15 | )
16 |
17 | func TestConsumerPublisherTracing(t *testing.T) {
18 | // Setup tracing
19 | otel.SetTracerProvider(tracesdk.NewTracerProvider())
20 | otel.SetTextMapPropagator(propagation.TraceContext{})
21 |
22 | // Setup amqp
23 | queueName := uuid.NewString()
24 | exchangeName := uuid.NewString()
25 | routingKey := uuid.NewString()
26 |
27 | connection := bunnify.NewConnection()
28 | if err := connection.Start(); err != nil {
29 | t.Fatal(err)
30 | }
31 |
32 | // Exercise consuming
33 | var actualTraceID trace.TraceID
34 | eventHandler := func(ctx context.Context, _ bunnify.ConsumableEvent[any]) error {
35 | actualTraceID = trace.SpanFromContext(ctx).SpanContext().TraceID()
36 | return nil
37 | }
38 |
39 | consumer := connection.NewConsumer(
40 | queueName,
41 | bunnify.WithBindingToExchange(exchangeName),
42 | bunnify.WithHandler(routingKey, eventHandler))
43 | if err := consumer.Consume(); err != nil {
44 | t.Fatal(err)
45 | }
46 |
47 | // Exercise publishing
48 | publisher := connection.NewPublisher()
49 | publishingContext, _ := otel.Tracer("amqp").Start(context.Background(), "publish-test")
50 |
51 | err := publisher.Publish(
52 | publishingContext,
53 | exchangeName,
54 | routingKey,
55 | bunnify.NewPublishableEvent(struct{}{}))
56 | if err != nil {
57 | t.Fatal(err)
58 | }
59 |
60 | time.Sleep(50 * time.Millisecond)
61 |
62 | if err := connection.Close(); err != nil {
63 | t.Fatal(err)
64 | }
65 |
66 | // Assert
67 | publishingTraceID := trace.SpanFromContext(publishingContext).SpanContext().TraceID()
68 | if actualTraceID != publishingTraceID {
69 | t.Fatalf("expected traceID %s, got %s", publishingTraceID, actualTraceID)
70 | }
71 |
72 | goleak.VerifyNone(t)
73 | }
74 |
--------------------------------------------------------------------------------
/tests/consumer_retries_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "testing"
8 | "time"
9 |
10 | "github.com/google/uuid"
11 | "github.com/pmorelli92/bunnify"
12 | "go.uber.org/goleak"
13 | )
14 |
15 | func TestConsumerRetriesShouldFailWhenNoQuorumQueues(t *testing.T) {
16 | // Setup
17 | queueName := uuid.NewString()
18 | exchangeName := uuid.NewString()
19 |
20 | // Exercise
21 | connection := bunnify.NewConnection()
22 | if err := connection.Start(); err != nil {
23 | t.Fatal(err)
24 | }
25 |
26 | consumer := connection.NewConsumer(
27 | queueName,
28 | bunnify.WithRetries(1),
29 | bunnify.WithBindingToExchange(exchangeName),
30 | bunnify.WithDefaultHandler(func(ctx context.Context, event bunnify.ConsumableEvent[json.RawMessage]) error {
31 | return nil
32 | }))
33 |
34 | err := consumer.Consume()
35 | if err == nil {
36 | t.Fatal("expected error as retry cannot be used without quorum queues")
37 | }
38 |
39 | if err := connection.Close(); err != nil {
40 | t.Fatal(err)
41 | }
42 |
43 | goleak.VerifyNone(t)
44 | }
45 |
46 | func TestConsumerRetries(t *testing.T) {
47 | // Setup
48 | queueName := uuid.NewString()
49 | exchangeName := uuid.NewString()
50 | routingKey := "order.orderCreated"
51 | expectedRetries := 2
52 |
53 | type orderCreated struct {
54 | ID string `json:"id"`
55 | }
56 |
57 | publishedOrderCreated := orderCreated{
58 | ID: uuid.NewString(),
59 | }
60 | publishedEvent := bunnify.NewPublishableEvent(
61 | publishedOrderCreated,
62 | bunnify.WithEventID("custom-event-id"),
63 | bunnify.WithCorrelationID("custom-correlation-id"),
64 | )
65 |
66 | actualProcessing := 0
67 | eventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
68 | actualProcessing++
69 | return fmt.Errorf("error, this event should be retried")
70 | }
71 |
72 | // Exercise
73 | connection := bunnify.NewConnection()
74 | if err := connection.Start(); err != nil {
75 | t.Fatal(err)
76 | }
77 |
78 | consumer := connection.NewConsumer(
79 | queueName,
80 | bunnify.WithQuorumQueue(),
81 | bunnify.WithRetries(expectedRetries),
82 | bunnify.WithBindingToExchange(exchangeName),
83 | bunnify.WithHandler(routingKey, eventHandler))
84 |
85 | if err := consumer.Consume(); err != nil {
86 | t.Fatal(err)
87 | }
88 |
89 | publisher := connection.NewPublisher()
90 |
91 | err := publisher.Publish(context.TODO(), exchangeName, routingKey, publishedEvent)
92 | if err != nil {
93 | t.Fatal(err)
94 | }
95 |
96 | time.Sleep(50 * time.Millisecond)
97 |
98 | if err := connection.Close(); err != nil {
99 | t.Fatal(err)
100 | }
101 |
102 | // Assert
103 | expectedProcessing := expectedRetries + 1
104 | if expectedProcessing != actualProcessing {
105 | t.Fatalf("expected processing %d, got %d", expectedProcessing, actualProcessing)
106 | }
107 |
108 | goleak.VerifyNone(t)
109 | }
110 |
111 | func TestConsumerRetriesWithDeadLetterQueue(t *testing.T) {
112 | // Setup
113 | queueName := uuid.NewString()
114 | deadLetterQueueName := uuid.NewString()
115 | exchangeName := uuid.NewString()
116 | routingKey := "order.orderCreated"
117 | expectedRetries := 2
118 |
119 | type orderCreated struct {
120 | ID string `json:"id"`
121 | }
122 |
123 | publishedOrderCreated := orderCreated{
124 | ID: uuid.NewString(),
125 | }
126 | publishedEvent := bunnify.NewPublishableEvent(
127 | publishedOrderCreated,
128 | bunnify.WithEventID("custom-event-id"),
129 | bunnify.WithCorrelationID("custom-correlation-id"),
130 | )
131 |
132 | actualProcessing := 0
133 | eventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
134 | actualProcessing++
135 | return fmt.Errorf("error, this event will go to dead-letter")
136 | }
137 |
138 | var deadEvent bunnify.ConsumableEvent[orderCreated]
139 | deadEventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
140 | deadEvent = event
141 | return nil
142 | }
143 |
144 | // Exercise
145 | connection := bunnify.NewConnection()
146 | if err := connection.Start(); err != nil {
147 | t.Fatal(err)
148 | }
149 |
150 | consumer := connection.NewConsumer(
151 | queueName,
152 | bunnify.WithQuorumQueue(),
153 | bunnify.WithRetries(expectedRetries),
154 | bunnify.WithBindingToExchange(exchangeName),
155 | bunnify.WithHandler(routingKey, eventHandler),
156 | bunnify.WithDeadLetterQueue(deadLetterQueueName))
157 |
158 | if err := consumer.Consume(); err != nil {
159 | t.Fatal(err)
160 | }
161 |
162 | deadLetterConsumer := connection.NewConsumer(
163 | deadLetterQueueName,
164 | bunnify.WithHandler(routingKey, deadEventHandler))
165 |
166 | if err := deadLetterConsumer.Consume(); err != nil {
167 | t.Fatal(err)
168 | }
169 |
170 | publisher := connection.NewPublisher()
171 |
172 | err := publisher.Publish(context.TODO(), exchangeName, routingKey, publishedEvent)
173 | if err != nil {
174 | t.Fatal(err)
175 | }
176 |
177 | time.Sleep(50 * time.Millisecond)
178 |
179 | if err := connection.Close(); err != nil {
180 | t.Fatal(err)
181 | }
182 |
183 | // Assert
184 | expectedProcessing := expectedRetries + 1
185 | if expectedProcessing != actualProcessing {
186 | t.Fatalf("expected processing %d, got %d", expectedProcessing, actualProcessing)
187 | }
188 |
189 | if publishedEvent.ID != deadEvent.ID {
190 | t.Fatalf("expected event ID %s, got %s", publishedEvent.ID, deadEvent.ID)
191 | }
192 |
193 | goleak.VerifyNone(t)
194 | }
195 |
--------------------------------------------------------------------------------
/tests/dead_letter_receives_event_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "testing"
7 | "time"
8 |
9 | "github.com/google/uuid"
10 | "github.com/pmorelli92/bunnify"
11 | "go.uber.org/goleak"
12 | )
13 |
14 | func TestDeadLetterReceivesEvent(t *testing.T) {
15 | // Setup
16 | queueName := uuid.NewString()
17 | deadLetterQueueName := uuid.NewString()
18 | exchangeName := uuid.NewString()
19 | routingKey := "order.orderCreated"
20 |
21 | type orderCreated struct {
22 | ID string `json:"id"`
23 | }
24 |
25 | publishedOrderCreated := orderCreated{
26 | ID: uuid.NewString(),
27 | }
28 | publishedEvent := bunnify.NewPublishableEvent(
29 | publishedOrderCreated,
30 | bunnify.WithEventID("custom-event-id"),
31 | bunnify.WithCorrelationID("custom-correlation-id"),
32 | )
33 |
34 | eventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
35 | return fmt.Errorf("error, this event will go to dead-letter")
36 | }
37 |
38 | var deadEvent bunnify.ConsumableEvent[orderCreated]
39 | deadEventHandler := func(ctx context.Context, event bunnify.ConsumableEvent[orderCreated]) error {
40 | deadEvent = event
41 | return nil
42 | }
43 |
44 | // Exercise
45 | connection := bunnify.NewConnection()
46 | if err := connection.Start(); err != nil {
47 | t.Fatal(err)
48 | }
49 |
50 | consumer := connection.NewConsumer(
51 | queueName,
52 | bunnify.WithQoS(2, 0),
53 | bunnify.WithBindingToExchange(exchangeName),
54 | bunnify.WithHandler(routingKey, eventHandler),
55 | bunnify.WithDeadLetterQueue(deadLetterQueueName))
56 |
57 | if err := consumer.Consume(); err != nil {
58 | t.Fatal(err)
59 | }
60 |
61 | deadLetterConsumer := connection.NewConsumer(
62 | deadLetterQueueName,
63 | bunnify.WithHandler(routingKey, deadEventHandler))
64 |
65 | if err := deadLetterConsumer.Consume(); err != nil {
66 | t.Fatal(err)
67 | }
68 |
69 | publisher := connection.NewPublisher()
70 |
71 | err := publisher.Publish(context.TODO(), exchangeName, routingKey, publishedEvent)
72 | if err != nil {
73 | t.Fatal(err)
74 | }
75 |
76 | time.Sleep(50 * time.Millisecond)
77 |
78 | if err := connection.Close(); err != nil {
79 | t.Fatal(err)
80 | }
81 |
82 | // Assert
83 | if publishedEvent.ID != deadEvent.ID {
84 | t.Fatalf("expected event ID %s, got %s", publishedEvent.ID, deadEvent.ID)
85 | }
86 | if publishedEvent.CorrelationID != deadEvent.CorrelationID {
87 | t.Fatalf("expected correlation ID %s, got %s", publishedEvent.CorrelationID, deadEvent.CorrelationID)
88 | }
89 | if !publishedEvent.Timestamp.Equal(deadEvent.Timestamp) {
90 | t.Fatalf("expected timestamp %s, got %s", publishedEvent.Timestamp, deadEvent.Timestamp)
91 | }
92 |
93 | if publishedOrderCreated.ID != deadEvent.Payload.ID {
94 | t.Fatalf("expected order created ID %s, got %s", publishedOrderCreated.ID, deadEvent.Payload.ID)
95 | }
96 | if exchangeName != deadEvent.DeliveryInfo.Exchange {
97 | t.Fatalf("expected exchange %s, got %s", exchangeName, deadEvent.DeliveryInfo.Exchange)
98 | }
99 | if queueName != deadEvent.DeliveryInfo.Queue {
100 | t.Fatalf("expected queue %s, got %s", queueName, deadEvent.DeliveryInfo.Queue)
101 | }
102 | if routingKey != deadEvent.DeliveryInfo.RoutingKey {
103 | t.Fatalf("expected routing key %s, got %s", routingKey, deadEvent.DeliveryInfo.RoutingKey)
104 | }
105 |
106 | goleak.VerifyNone(t)
107 | }
108 |
--------------------------------------------------------------------------------
/tests/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | rabbitmq:
3 | image: "rabbitmq:3-management"
4 | ports:
5 | - "15672:15672"
6 | - "5672:5672"
7 |
--------------------------------------------------------------------------------
/tests/go_routines_not_leaked_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "testing"
7 | "time"
8 |
9 | "github.com/google/uuid"
10 | "github.com/pmorelli92/bunnify"
11 | "go.uber.org/goleak"
12 | )
13 |
14 | func TestGoRoutinesAreNotLeaked(t *testing.T) {
15 | // Setup
16 | ticker := time.NewTicker(2 * time.Second)
17 | connection := bunnify.NewConnection()
18 | if err := connection.Start(); err != nil {
19 | t.Fatal(err)
20 | }
21 |
22 | // Exercise
23 | for i := 0; i < 100; i++ {
24 | c := connection.NewConsumer(
25 | uuid.NewString(),
26 | bunnify.WithBindingToExchange(uuid.NewString()),
27 | bunnify.WithDefaultHandler(func(ctx context.Context, event bunnify.ConsumableEvent[json.RawMessage]) error {
28 | return nil
29 | }))
30 |
31 | if err := c.Consume(); err != nil {
32 | t.Fatal(err)
33 | }
34 | }
35 |
36 | // While technically the waits are not needed I added them
37 | // so I can visualize on the management that channels are in fact
38 | // opened and then closed before the tests finishes.
39 | <-ticker.C
40 | if err := connection.Close(); err != nil {
41 | t.Fatal(err)
42 | }
43 | <-ticker.C
44 |
45 | // Assert
46 | goleak.VerifyNone(t)
47 | }
48 |
--------------------------------------------------------------------------------
/tracing.go:
--------------------------------------------------------------------------------
1 | package bunnify
2 |
3 | import (
4 | "context"
5 |
6 | amqp "github.com/rabbitmq/amqp091-go"
7 | "go.opentelemetry.io/otel"
8 | "go.opentelemetry.io/otel/propagation"
9 | )
10 |
11 | // inject the span context to amqp table
12 | func injectToHeaders(ctx context.Context) amqp.Table {
13 | carrier := propagation.MapCarrier{}
14 | otel.GetTextMapPropagator().Inject(ctx, carrier)
15 |
16 | header := amqp.Table{}
17 | for k, v := range carrier {
18 | header[k] = v
19 | }
20 | return header
21 | }
22 |
23 | // extract the amqp table to a span context
24 | func extractToContext(headers amqp.Table) context.Context {
25 | carrier := propagation.MapCarrier{}
26 | for k, v := range headers {
27 | value, ok := v.(string)
28 | if ok {
29 | carrier[k] = value
30 | }
31 | }
32 |
33 | return otel.GetTextMapPropagator().Extract(context.TODO(), carrier)
34 | }
35 |
--------------------------------------------------------------------------------