├── .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 | [![Go Report Card](https://goreportcard.com/badge/github.com/pmorelli92/bunnify)](https://goreportcard.com/report/github.com/pmorelli92/bunnify) 8 | [![GitHub license](https://img.shields.io/github/license/pmorelli92/bunnify)](LICENSE) 9 | [![Tests](https://github.com/pmorelli92/bunnify/actions/workflows/main.yaml/badge.svg?branch=main)](https://github.com/pmorelli92/bunnify/actions/workflows/main.yaml) 10 | [![Coverage Status](https://coveralls.io/repos/github/pmorelli92/bunnify/badge.svg?branch=main&kill_cache=1)](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 | --------------------------------------------------------------------------------