lepus 0.0.1.rc2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +0 -1
- data/Gemfile +5 -0
- data/Gemfile.lock +12 -1
- data/README.md +179 -0
- data/config.ru +14 -0
- data/docs/README.md +80 -0
- data/docs/cli.md +108 -0
- data/docs/configuration.md +171 -0
- data/docs/consumers.md +168 -0
- data/docs/getting-started.md +136 -0
- data/docs/images/lepus-web.png +0 -0
- data/docs/middleware.md +240 -0
- data/docs/producers.md +173 -0
- data/docs/prometheus.md +112 -0
- data/docs/rails.md +161 -0
- data/docs/supervisor.md +112 -0
- data/docs/testing.md +141 -0
- data/docs/web.md +85 -0
- data/examples/grafana-dashboard.json +450 -0
- data/gemfiles/Gemfile.rails-5.2 +1 -0
- data/gemfiles/Gemfile.rails-5.2.lock +59 -46
- data/gemfiles/Gemfile.rails-6.1 +1 -0
- data/gemfiles/Gemfile.rails-6.1.lock +72 -58
- data/gemfiles/Gemfile.rails-7.2.lock +8 -1
- data/gemfiles/Gemfile.rails-8.0.lock +8 -1
- data/lepus.gemspec +5 -1
- data/lib/lepus/cli.rb +24 -0
- data/lib/lepus/configuration.rb +42 -0
- data/lib/lepus/consumer.rb +12 -0
- data/lib/lepus/consumers/handler.rb +3 -1
- data/lib/lepus/consumers/stats.rb +70 -0
- data/lib/lepus/consumers/stats_registry.rb +29 -0
- data/lib/lepus/consumers/worker.rb +7 -6
- data/lib/lepus/process.rb +4 -4
- data/lib/lepus/process_registry/backend.rb +49 -0
- data/lib/lepus/process_registry/file_backend.rb +108 -0
- data/lib/lepus/process_registry/message_builder.rb +72 -0
- data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
- data/lib/lepus/process_registry.rb +28 -67
- data/lib/lepus/prometheus/collector.rb +149 -0
- data/lib/lepus/prometheus/instrumentation.rb +168 -0
- data/lib/lepus/prometheus.rb +48 -0
- data/lib/lepus/publisher.rb +3 -1
- data/lib/lepus/supervisor.rb +9 -2
- data/lib/lepus/version.rb +1 -1
- data/lib/lepus/web/aggregator.rb +154 -0
- data/lib/lepus/web/api.rb +132 -0
- data/lib/lepus/web/app.rb +37 -0
- data/lib/lepus/web/management_api.rb +192 -0
- data/lib/lepus/web/respond_with.rb +28 -0
- data/lib/lepus/web.rb +238 -0
- data/lib/lepus.rb +5 -0
- data/test_offline.html +189 -0
- data/web/assets/css/styles.css +635 -0
- data/web/assets/js/app.js +6 -0
- data/web/assets/js/bootstrap.js +20 -0
- data/web/assets/js/controllers/connection_controller.js +44 -0
- data/web/assets/js/controllers/dashboard_controller.js +499 -0
- data/web/assets/js/controllers/queue_controller.js +17 -0
- data/web/assets/js/controllers/theme_controller.js +31 -0
- data/web/assets/js/offline-manager.js +233 -0
- data/web/assets/js/service-worker-manager.js +65 -0
- data/web/index.html +159 -0
- data/web/sw.js +144 -0
- metadata +103 -5
data/docs/consumers.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Consumers
|
|
2
|
+
|
|
3
|
+
Consumers subscribe to a queue, receive messages, and process them. A consumer is a subclass of `Lepus::Consumer` with a `configure` call and a `perform` method.
|
|
4
|
+
|
|
5
|
+
## Minimal example
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class OrdersConsumer < Lepus::Consumer
|
|
9
|
+
configure(
|
|
10
|
+
queue: 'orders',
|
|
11
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
12
|
+
routing_key: ['order.*']
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
use :json, symbolize_keys: true
|
|
16
|
+
|
|
17
|
+
def perform(message)
|
|
18
|
+
Order.create!(message.payload)
|
|
19
|
+
:ack
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## `configure` DSL
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
configure(
|
|
28
|
+
queue: 'orders', # string or hash
|
|
29
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
30
|
+
routing_key: ['order.*', 'invoice.created'], # array or string
|
|
31
|
+
prefetch: 1, # channel QoS
|
|
32
|
+
retry_queue: { delay: 5_000 }, # or true, or false
|
|
33
|
+
error_queue: true, # or queue name, or false
|
|
34
|
+
process: { name: :default, threads: 5 }, # worker assignment
|
|
35
|
+
channel: { pool_size: 1, shutdown_timeout: 60, abort_on_exception: false }
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Key | Purpose |
|
|
40
|
+
|-----|---------|
|
|
41
|
+
| `queue` | The queue name — string, or a hash for advanced options (`name`, `durable`, `arguments`, …). |
|
|
42
|
+
| `exchange` | Bind to this exchange. String, or hash (`name`, `type`, `durable`, `auto_delete`). |
|
|
43
|
+
| `routing_key` | Binding routing key(s). |
|
|
44
|
+
| `prefetch` | Channel QoS (`basic.qos`). Leave at `1` unless you know you need more. |
|
|
45
|
+
| `retry_queue` | When truthy, sets up a retry queue with a delay exchange. Messages requeued via `:requeue` or `max_retry` go here. |
|
|
46
|
+
| `error_queue` | Queue name (string) or `true` to auto-name. Messages that exhaust retries land here. |
|
|
47
|
+
| `process.name` | The named worker pool this consumer runs in — see `Lepus.configure.worker(:name)`. |
|
|
48
|
+
| `process.threads` | Threads inside the consumer's worker dedicated to this consumer. |
|
|
49
|
+
|
|
50
|
+
## The `perform` method
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
def perform(message)
|
|
54
|
+
# ...
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`message` is a `Lepus::Message` with:
|
|
59
|
+
|
|
60
|
+
- `#payload` — the decoded body (raw bytes, or parsed data if a decoding middleware is in the chain).
|
|
61
|
+
- `#delivery_info` — `#exchange`, `#routing_key`, `#redelivered?`, `#delivery_tag`, `#consumer_tag`.
|
|
62
|
+
- `#metadata` — `#headers`, `#content_type`, `#content_encoding`, `#correlation_id`, `#message_id`, `#reply_to`, `#type`, `#timestamp`, `#user_id`, `#app_id`, `#priority`.
|
|
63
|
+
|
|
64
|
+
Return a disposition symbol or call one of the helpers:
|
|
65
|
+
|
|
66
|
+
| Return | Helper | Effect |
|
|
67
|
+
|--------|--------|--------|
|
|
68
|
+
| `:ack` | `ack!` | Acknowledge the message. |
|
|
69
|
+
| `:reject` | `reject!` | Reject without requeue (drops, or goes to DLX if configured). |
|
|
70
|
+
| `:requeue` | `requeue!` | Reject with requeue — back of the queue. |
|
|
71
|
+
| `:nack` | `nack!` | Negative-ack. |
|
|
72
|
+
|
|
73
|
+
A `perform` that returns nothing implicitly acks. A `perform` that raises is logged, `on_thread_error` fires, and the message is rejected.
|
|
74
|
+
|
|
75
|
+
## Middleware
|
|
76
|
+
|
|
77
|
+
Add middlewares to a consumer with `use`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class OrdersConsumer < Lepus::Consumer
|
|
81
|
+
use :json, symbolize_keys: true
|
|
82
|
+
use :max_retry, retries: 5, error_queue: 'orders.error'
|
|
83
|
+
use :exception_logger
|
|
84
|
+
use :honeybadger
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Built-in middlewares:
|
|
89
|
+
|
|
90
|
+
| Name | Purpose |
|
|
91
|
+
|------|---------|
|
|
92
|
+
| `:json` | Parse JSON body into a hash. |
|
|
93
|
+
| `:max_retry` | Track `x-death` headers; after N retries, route to error queue. |
|
|
94
|
+
| `:exception_logger` | Log unhandled exceptions with backtrace. |
|
|
95
|
+
| `:honeybadger` | Notify Honeybadger on exceptions. |
|
|
96
|
+
| `:unique` | Idempotent dedupe by `correlation_id` (requires storage). |
|
|
97
|
+
|
|
98
|
+
Write your own — see [middleware.md](middleware.md).
|
|
99
|
+
|
|
100
|
+
## Error handling
|
|
101
|
+
|
|
102
|
+
A `perform` that raises:
|
|
103
|
+
|
|
104
|
+
1. The message is rejected by default.
|
|
105
|
+
2. `config.on_thread_error` is called with the exception.
|
|
106
|
+
3. If `:exception_logger` middleware is in the chain, it logs with backtrace.
|
|
107
|
+
4. If `:max_retry` is in the chain, it tracks retry count via RabbitMQ's `x-death` header and redirects to an error queue after N rejections.
|
|
108
|
+
|
|
109
|
+
## Retries
|
|
110
|
+
|
|
111
|
+
The built-in retry pattern uses RabbitMQ's dead-letter machinery:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
configure(
|
|
115
|
+
queue: 'orders',
|
|
116
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
117
|
+
routing_key: 'order.*',
|
|
118
|
+
retry_queue: { delay: 5_000 }, # 5-second delay before retry
|
|
119
|
+
error_queue: 'orders.error'
|
|
120
|
+
)
|
|
121
|
+
use :max_retry, retries: 5, error_queue: 'orders.error'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
On rejection:
|
|
125
|
+
|
|
126
|
+
1. `max_retry` inspects `x-death` headers.
|
|
127
|
+
2. If retries are below the limit, it re-publishes to the retry queue.
|
|
128
|
+
3. After TTL, the retry queue dead-letters back to the original queue for another attempt.
|
|
129
|
+
4. After N failures, the middleware routes to the error queue.
|
|
130
|
+
|
|
131
|
+
## Worker assignment
|
|
132
|
+
|
|
133
|
+
Assign a consumer to a named worker pool:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
configure(
|
|
137
|
+
queue: 'orders',
|
|
138
|
+
exchange: { name: 'orders', type: :topic },
|
|
139
|
+
process: { name: :high_priority, threads: 10 }
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Then in the global config:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
Lepus.configure do |config|
|
|
147
|
+
config.worker(:high_priority) do |w|
|
|
148
|
+
w.pool_size = 20
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Consumers assigned to the same worker share a single subprocess.
|
|
154
|
+
|
|
155
|
+
## Logging
|
|
156
|
+
|
|
157
|
+
Inside `perform`, `logger` is the Lepus logger (tagged with the consumer class name):
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
def perform(message)
|
|
161
|
+
logger.info("processing #{message.delivery_info.routing_key}")
|
|
162
|
+
# ...
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Testing
|
|
167
|
+
|
|
168
|
+
See [testing.md](testing.md) for the test-mode helpers.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# Gemfile
|
|
7
|
+
gem 'lepus'
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bundle install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You'll also need a running RabbitMQ instance. For local dev:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
docker run -d --rm -p 5672:5672 -p 15672:15672 rabbitmq:3-management
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configure
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# config/initializers/lepus.rb (Rails) or your bootstrap file
|
|
24
|
+
Lepus.configure do |config|
|
|
25
|
+
config.rabbitmq_url = ENV.fetch('RABBITMQ_URL', 'amqp://guest:guest@localhost:5672')
|
|
26
|
+
config.connection_name = 'my-service'
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
See [configuration.md](configuration.md) for the full DSL.
|
|
31
|
+
|
|
32
|
+
## Define a consumer
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# app/consumers/orders_consumer.rb
|
|
36
|
+
class OrdersConsumer < Lepus::Consumer
|
|
37
|
+
configure(
|
|
38
|
+
queue: 'orders',
|
|
39
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
40
|
+
routing_key: ['order.*']
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
use :json, symbolize_keys: true
|
|
44
|
+
|
|
45
|
+
def perform(message)
|
|
46
|
+
# message.payload => the decoded JSON body (a hash, due to the :json middleware)
|
|
47
|
+
# message.delivery_info => exchange, routing_key, redelivered?
|
|
48
|
+
# message.metadata => headers, content_type, correlation_id, etc.
|
|
49
|
+
Order.create!(message.payload)
|
|
50
|
+
:ack
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `perform` method returns a symbol indicating disposition:
|
|
56
|
+
|
|
57
|
+
- `:ack` — acknowledge; RabbitMQ removes the message.
|
|
58
|
+
- `:reject` — reject; message is dropped (or routed to a dead-letter exchange if configured).
|
|
59
|
+
- `:requeue` — reject and requeue for another delivery attempt.
|
|
60
|
+
- `:nack` — negative-ack without requeue (similar to `:reject`).
|
|
61
|
+
|
|
62
|
+
`ack!`, `reject!`, `requeue!`, `nack!` are also available as helper methods.
|
|
63
|
+
|
|
64
|
+
## Define a producer
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# app/producers/orders_producer.rb
|
|
68
|
+
class OrdersProducer < Lepus::Producer
|
|
69
|
+
configure(
|
|
70
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
71
|
+
publish: { persistent: true }
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
use :json
|
|
75
|
+
use :correlation_id
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Publish:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
OrdersProducer.publish(
|
|
83
|
+
{ order_id: 42, total: 99.99 },
|
|
84
|
+
routing_key: 'order.created'
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Run
|
|
89
|
+
|
|
90
|
+
### One-off (development)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bundle exec lepus start OrdersConsumer
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Flags worth knowing:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bundle exec lepus start OrdersConsumer PaymentsConsumer \
|
|
100
|
+
--require_file config/environment.rb \
|
|
101
|
+
--debug
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [cli.md](cli.md).
|
|
105
|
+
|
|
106
|
+
### As a long-running service
|
|
107
|
+
|
|
108
|
+
In production, run `lepus start` with all your consumer classes (or auto-load them — see below) under your favorite process supervisor (systemd, Foreman, Kamal, Kubernetes).
|
|
109
|
+
|
|
110
|
+
Auto-load consumers from a directory:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
Lepus.configure do |config|
|
|
114
|
+
config.consumers_directory = 'app/consumers'
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
bundle exec lepus start # with no class args, starts all consumers from consumers_directory
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Monitor
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
bundle exec lepus web --port 9292
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Visit http://localhost:9292 to see consumer status, throughput, and recent activity. See [web.md](web.md).
|
|
129
|
+
|
|
130
|
+
## Next steps
|
|
131
|
+
|
|
132
|
+
- [Consumers](consumers.md) — full consumer DSL, retries, error handling
|
|
133
|
+
- [Producers](producers.md) — exchange config, publishing options, hooks
|
|
134
|
+
- [Middleware](middleware.md) — built-in middlewares and writing your own
|
|
135
|
+
- [Supervisor](supervisor.md) — process model, graceful shutdown, worker pools
|
|
136
|
+
- [Rails integration](rails.md) — Railtie, executor wrapping
|
|
Binary file
|
data/docs/middleware.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Middleware
|
|
2
|
+
|
|
3
|
+
Lepus middlewares form a chain around each message — both for producers (around each publish) and consumers (around each delivery). Every middleware gets the message and the next app in the chain:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class MyMiddleware < Lepus::Middleware
|
|
7
|
+
def initialize(option: nil)
|
|
8
|
+
@option = option
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(message, app)
|
|
12
|
+
# pre-processing
|
|
13
|
+
new_message = message.mutate(payload: transform(message.payload))
|
|
14
|
+
# pass down the chain (and let the consumer/publisher actually run)
|
|
15
|
+
result = app.call(new_message)
|
|
16
|
+
# post-processing
|
|
17
|
+
result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def transform(payload) = payload
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Registering
|
|
27
|
+
|
|
28
|
+
### Per consumer / per producer
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class OrdersConsumer < Lepus::Consumer
|
|
32
|
+
use :json, symbolize_keys: true # built-in by symbol
|
|
33
|
+
use :max_retry, retries: 5
|
|
34
|
+
use MyMiddleware, option: 'value' # your own class
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Globally
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Lepus.configure do |config|
|
|
42
|
+
config.consumer_middlewares do |chain|
|
|
43
|
+
chain.use :json, symbolize_keys: true
|
|
44
|
+
chain.use :exception_logger
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
config.producer_middlewares do |chain|
|
|
48
|
+
chain.use :json
|
|
49
|
+
chain.use :correlation_id
|
|
50
|
+
chain.use :instrumentation
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Global middlewares run **before** per-class middlewares.
|
|
56
|
+
|
|
57
|
+
## Built-in consumer middlewares
|
|
58
|
+
|
|
59
|
+
### `:json`
|
|
60
|
+
|
|
61
|
+
Parse the payload from JSON.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
use :json, symbolize_keys: true, on_error: proc { :reject }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Options:
|
|
68
|
+
|
|
69
|
+
- `symbolize_keys: true` — hash keys become symbols.
|
|
70
|
+
- `on_error:` — a callable that takes the exception; its return becomes the disposition (default: `:reject`).
|
|
71
|
+
|
|
72
|
+
### `:max_retry`
|
|
73
|
+
|
|
74
|
+
Track retry count via RabbitMQ `x-death` headers and route to an error queue after N attempts.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
use :max_retry, retries: 5, error_queue: 'orders.error'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Requires the consumer's `configure` to declare a `retry_queue:` so requeued messages loop through a delay queue.
|
|
81
|
+
|
|
82
|
+
### `:exception_logger`
|
|
83
|
+
|
|
84
|
+
Catch exceptions from downstream middlewares/perform, log with backtrace, then re-raise (so higher-level error handling still sees the exception).
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
use :exception_logger
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `:honeybadger`
|
|
91
|
+
|
|
92
|
+
Notify Honeybadger on exceptions. Requires the `honeybadger` gem.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
use :honeybadger
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `:unique`
|
|
99
|
+
|
|
100
|
+
Dedupe messages by `correlation_id`. Requires a storage backend (your code wires Redis or similar).
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
use :unique, store: MyRedisStore
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Built-in producer middlewares
|
|
107
|
+
|
|
108
|
+
### `:json`
|
|
109
|
+
|
|
110
|
+
Serialize a hash payload as JSON; set `content_type` to `application/json`.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
use :json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `:correlation_id`
|
|
117
|
+
|
|
118
|
+
Auto-generate a UUID correlation id if one isn't already set on the message.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
use :correlation_id
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `:header`
|
|
125
|
+
|
|
126
|
+
Add headers to every publish.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
use :header, 'X-Service', 'orders-api'
|
|
130
|
+
use :header, 'X-Request-Id', -> { Current.request_id }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The value can be a static value or a callable.
|
|
134
|
+
|
|
135
|
+
### `:instrumentation`
|
|
136
|
+
|
|
137
|
+
Emit `ActiveSupport::Notifications` events before and after each publish. Event name: `publish.lepus`.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
use :instrumentation
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Subscribe:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
ActiveSupport::Notifications.subscribe('publish.lepus') do |name, start, finish, id, payload|
|
|
147
|
+
puts "published to #{payload[:exchange]} in #{(finish - start) * 1000}ms"
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `:unique`
|
|
152
|
+
|
|
153
|
+
Drop duplicate publishes. Requires a storage backend.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
use :unique, store: MyRedisStore
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Writing your own
|
|
160
|
+
|
|
161
|
+
### Consumer middleware
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class LogLevelMiddleware < Lepus::Middleware
|
|
165
|
+
def initialize(level: :info)
|
|
166
|
+
@level = level
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def call(message, app)
|
|
170
|
+
Lepus.logger.public_send(@level, "Processing: #{message.payload.inspect}")
|
|
171
|
+
app.call(message)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class OrdersConsumer < Lepus::Consumer
|
|
176
|
+
use LogLevelMiddleware, level: :debug
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Producer middleware
|
|
181
|
+
|
|
182
|
+
Same interface. Typical use: decorate the message with metadata before it's published.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
class TenantScopingMiddleware < Lepus::Middleware
|
|
186
|
+
def call(message, app)
|
|
187
|
+
with_tenant = message.mutate(metadata: message.metadata.merge(headers: message.metadata[:headers].merge('X-Tenant-Id' => Current.tenant_id)))
|
|
188
|
+
app.call(with_tenant)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Mutating a message
|
|
194
|
+
|
|
195
|
+
`message.mutate(**kwargs)` returns a new message with updated fields — payload, metadata, delivery_info. Don't mutate in place; build a new one and pass it down.
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
app.call(message.mutate(payload: transformed_payload))
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Order of execution
|
|
202
|
+
|
|
203
|
+
For a consumer:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
incoming delivery
|
|
207
|
+
→ global consumer middlewares (in config order)
|
|
208
|
+
→ per-consumer middlewares (in class-file order)
|
|
209
|
+
→ perform(message)
|
|
210
|
+
→ each middleware unwinds (post-processing)
|
|
211
|
+
→ disposition returned
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
For a producer:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
publish(payload)
|
|
218
|
+
→ per-producer middlewares
|
|
219
|
+
→ global producer middlewares
|
|
220
|
+
→ Bunny publish
|
|
221
|
+
→ unwind
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Conditional middleware
|
|
225
|
+
|
|
226
|
+
Middleware can decide not to call the next app — effectively short-circuiting:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
class SkipDuringMaintenanceMiddleware < Lepus::Middleware
|
|
230
|
+
def call(message, app)
|
|
231
|
+
if Feature.maintenance_mode?
|
|
232
|
+
Lepus.logger.warn('skipping publish during maintenance')
|
|
233
|
+
return # do NOT call app.call(message)
|
|
234
|
+
end
|
|
235
|
+
app.call(message)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
For consumers, returning early means the consumer's `perform` never runs. The return value of `call` becomes the disposition.
|
data/docs/producers.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Producers
|
|
2
|
+
|
|
3
|
+
A producer publishes messages to an exchange. It's a subclass of `Lepus::Producer` with a `configure` call and whatever convenience methods you want on top.
|
|
4
|
+
|
|
5
|
+
## Minimal example
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class OrdersProducer < Lepus::Producer
|
|
9
|
+
configure(
|
|
10
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
11
|
+
publish: { persistent: true }
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
use :json
|
|
15
|
+
use :correlation_id
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Publish from anywhere in your app:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
OrdersProducer.publish(
|
|
23
|
+
{ order_id: 42, total: 99.99 },
|
|
24
|
+
routing_key: 'order.created'
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## `configure` DSL
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
configure(
|
|
32
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
33
|
+
publish: { persistent: true, mandatory: false }
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Key | Purpose |
|
|
38
|
+
|-----|---------|
|
|
39
|
+
| `exchange` | String (name only) or hash (`name`, `type`, `durable`, `auto_delete`). Auto-declared on first publish. |
|
|
40
|
+
| `publish` | Default publish options merged into every `publish` call: `persistent`, `mandatory`, `content_type`, `expiration`, etc. |
|
|
41
|
+
|
|
42
|
+
## Publishing
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
OrdersProducer.publish(payload, routing_key: 'order.created')
|
|
46
|
+
OrdersProducer.publish(payload, routing_key: 'order.paid', headers: { tenant_id: 123 })
|
|
47
|
+
OrdersProducer.publish(payload, routing_key: 'order.created', expiration: 60_000)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All Bunny publish options are supported. Frequently useful:
|
|
51
|
+
|
|
52
|
+
| Option | Purpose |
|
|
53
|
+
|--------|---------|
|
|
54
|
+
| `routing_key` | Routing key. |
|
|
55
|
+
| `persistent` | Persist the message across RabbitMQ restarts. |
|
|
56
|
+
| `headers` | Custom headers. |
|
|
57
|
+
| `correlation_id` | RPC pattern correlation. Auto-set by `:correlation_id` middleware. |
|
|
58
|
+
| `content_type` | MIME type. Auto-set to `application/json` by `:json` middleware. |
|
|
59
|
+
| `expiration` | Per-message TTL in milliseconds. |
|
|
60
|
+
| `mandatory` | Return the message if it can't be routed. |
|
|
61
|
+
|
|
62
|
+
## Payload types
|
|
63
|
+
|
|
64
|
+
- A Hash — pair with `:json` middleware to serialize.
|
|
65
|
+
- A String — sent as-is.
|
|
66
|
+
- Anything else — pair with a middleware that turns it into bytes.
|
|
67
|
+
|
|
68
|
+
## Middleware
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class OrdersProducer < Lepus::Producer
|
|
72
|
+
use :json
|
|
73
|
+
use :correlation_id
|
|
74
|
+
use :instrumentation
|
|
75
|
+
use :header, 'X-Service', 'orders-api'
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Built-in producer middlewares:
|
|
80
|
+
|
|
81
|
+
| Name | Purpose |
|
|
82
|
+
|------|---------|
|
|
83
|
+
| `:json` | Serialize a hash into JSON; sets `content_type`. |
|
|
84
|
+
| `:correlation_id` | Auto-generate a UUID `correlation_id` if absent. |
|
|
85
|
+
| `:header` | Add a static or dynamic header: `use :header, 'X-Foo', 'bar'`. |
|
|
86
|
+
| `:instrumentation` | Emit `ActiveSupport::Notifications` events around each publish. |
|
|
87
|
+
| `:unique` | Reject duplicate publishes (by correlation id — needs external storage). |
|
|
88
|
+
|
|
89
|
+
Write your own — see [middleware.md](middleware.md).
|
|
90
|
+
|
|
91
|
+
## Convenience class methods
|
|
92
|
+
|
|
93
|
+
Wrap `publish` with domain-specific signatures:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class OrdersProducer < Lepus::Producer
|
|
97
|
+
configure(exchange: { name: 'orders', type: :topic, durable: true })
|
|
98
|
+
use :json
|
|
99
|
+
use :correlation_id
|
|
100
|
+
|
|
101
|
+
def self.order_created(order)
|
|
102
|
+
publish(
|
|
103
|
+
{ order_id: order.id, total: order.total, created_at: order.created_at },
|
|
104
|
+
routing_key: 'order.created'
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.order_shipped(order)
|
|
109
|
+
publish(
|
|
110
|
+
{ order_id: order.id, shipped_at: order.shipped_at },
|
|
111
|
+
routing_key: 'order.shipped'
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# In your code:
|
|
117
|
+
OrdersProducer.order_created(order)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Connection pooling
|
|
121
|
+
|
|
122
|
+
Producers share a single connection pool across the process:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Lepus.configure do |config|
|
|
126
|
+
config.producer do |p|
|
|
127
|
+
p.pool_size = 5
|
|
128
|
+
p.pool_timeout = 5.0
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Enable / disable publishing
|
|
134
|
+
|
|
135
|
+
Useful when you want to disable side effects in tests or a specific environment.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# Globally
|
|
139
|
+
Lepus::Producers.disable!
|
|
140
|
+
Lepus::Producers.enabled?(OrdersProducer) # => false
|
|
141
|
+
|
|
142
|
+
# By producer class
|
|
143
|
+
Lepus::Producers.disable!(OrdersProducer)
|
|
144
|
+
|
|
145
|
+
# By exchange name
|
|
146
|
+
Lepus::Producers.disable!('orders')
|
|
147
|
+
|
|
148
|
+
# Block-scoped
|
|
149
|
+
Lepus::Producers.without_publishing do
|
|
150
|
+
OrdersProducer.publish(...) # no-op
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
Lepus::Producers.with_publishing do
|
|
154
|
+
OrdersProducer.publish(...) # forced through even if disabled
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Error handling
|
|
159
|
+
|
|
160
|
+
A publish can fail for many reasons — connection down, channel closed, etc. By default, Bunny retries with exponential backoff within `recovery_attempts`. For anything beyond that, you catch and handle it:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
begin
|
|
164
|
+
OrdersProducer.order_created(order)
|
|
165
|
+
rescue Bunny::Exception => e
|
|
166
|
+
Rails.logger.error("publish failed: #{e.message}")
|
|
167
|
+
# queue for later retry, fall back to a DB outbox, etc.
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Testing
|
|
172
|
+
|
|
173
|
+
See [testing.md](testing.md) — there's a `Lepus::Testing.producer_messages(ProducerClass)` helper that captures publishes in-memory.
|