lepus 0.0.1.beta2 → 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/.github/workflows/linter.yml +21 -0
- data/.github/workflows/specs.yml +93 -13
- data/.gitignore +2 -0
- data/.rubocop.yml +10 -0
- data/.tool-versions +1 -1
- data/Gemfile +7 -0
- data/Gemfile.lock +36 -9
- data/Makefile +19 -0
- data/README.md +562 -7
- data/bin/setup +5 -2
- data/config.ru +14 -0
- data/docker-compose.yml +5 -3
- 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 +7 -0
- data/gemfiles/{rails52.gemfile.lock → Gemfile.rails-5.2.lock} +102 -69
- data/gemfiles/Gemfile.rails-6.1 +7 -0
- data/gemfiles/{rails61.gemfile.lock → Gemfile.rails-6.1.lock} +113 -79
- data/gemfiles/{rails52.gemfile → Gemfile.rails-7.2} +1 -1
- data/gemfiles/Gemfile.rails-7.2.lock +321 -0
- data/gemfiles/{rails61.gemfile → Gemfile.rails-8.0} +1 -1
- data/gemfiles/Gemfile.rails-8.0.lock +322 -0
- data/lepus.gemspec +7 -1
- data/lib/lepus/cli.rb +35 -4
- data/lib/lepus/configuration.rb +107 -0
- data/lib/lepus/connection_pool.rb +135 -0
- data/lib/lepus/consumer.rb +59 -41
- data/lib/lepus/consumers/config.rb +183 -0
- data/lib/lepus/consumers/handler.rb +56 -0
- data/lib/lepus/consumers/middleware_chain.rb +22 -0
- data/lib/lepus/consumers/middlewares/exception_logger.rb +27 -0
- data/lib/lepus/consumers/middlewares/honeybadger.rb +33 -0
- data/lib/lepus/consumers/middlewares/json.rb +37 -0
- data/lib/lepus/consumers/middlewares/max_retry.rb +83 -0
- data/lib/lepus/consumers/middlewares/unique.rb +65 -0
- data/lib/lepus/consumers/stats.rb +70 -0
- data/lib/lepus/consumers/stats_registry.rb +29 -0
- data/lib/lepus/consumers/worker.rb +141 -0
- data/lib/lepus/consumers/worker_factory.rb +124 -0
- data/lib/lepus/consumers.rb +6 -0
- data/lib/lepus/message/delivery_info.rb +72 -0
- data/lib/lepus/message/metadata.rb +99 -0
- data/lib/lepus/message.rb +88 -5
- data/lib/lepus/middleware_chain.rb +83 -0
- data/lib/lepus/primitive/hash.rb +29 -0
- data/lib/lepus/process.rb +24 -24
- 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 +56 -23
- data/lib/lepus/processes/base.rb +0 -5
- data/lib/lepus/processes/callbacks.rb +3 -0
- data/lib/lepus/processes/interruptible.rb +4 -8
- data/lib/lepus/processes/procline.rb +1 -1
- data/lib/lepus/processes/registrable.rb +1 -1
- data/lib/lepus/processes/runnable.rb +1 -1
- data/lib/lepus/processes.rb +15 -0
- data/lib/lepus/producer.rb +141 -30
- data/lib/lepus/producers/config.rb +46 -0
- data/lib/lepus/producers/definition.rb +48 -0
- data/lib/lepus/producers/hooks.rb +170 -0
- data/lib/lepus/producers/middleware_chain.rb +22 -0
- data/lib/lepus/producers/middlewares/correlation_id.rb +37 -0
- data/lib/lepus/producers/middlewares/header.rb +47 -0
- data/lib/lepus/producers/middlewares/instrumentation.rb +30 -0
- data/lib/lepus/producers/middlewares/json.rb +47 -0
- data/lib/lepus/producers/middlewares/unique.rb +67 -0
- data/lib/lepus/producers.rb +7 -0
- 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 +67 -0
- data/lib/lepus/supervisor/children_pipes.rb +25 -0
- data/lib/lepus/supervisor/lifecycle_hooks.rb +50 -0
- data/lib/lepus/supervisor/pidfiled.rb +1 -1
- data/lib/lepus/supervisor/registry_cleaner.rb +22 -0
- data/lib/lepus/supervisor.rb +129 -25
- data/lib/lepus/testing/exchange.rb +95 -0
- data/lib/lepus/testing/message_builder.rb +177 -0
- data/lib/lepus/testing/rspec_matchers.rb +258 -0
- data/lib/lepus/testing.rb +210 -0
- data/lib/lepus/unique.rb +18 -0
- 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 +39 -28
- 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 +177 -18
- data/lib/lepus/consumer_config.rb +0 -149
- data/lib/lepus/consumer_wrapper.rb +0 -46
- data/lib/lepus/lifecycle_hooks.rb +0 -49
- data/lib/lepus/middlewares/honeybadger.rb +0 -23
- data/lib/lepus/middlewares/json.rb +0 -35
- data/lib/lepus/middlewares/max_retry.rb +0 -57
- data/lib/lepus/processes/consumer.rb +0 -113
- data/lib/lepus/supervisor/config.rb +0 -45
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.
|
data/docs/prometheus.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Prometheus metrics
|
|
2
|
+
|
|
3
|
+
Lepus ships an optional integration with [`prometheus_exporter`](https://github.com/discourse/prometheus_exporter). Consumer and producer processes forward metric payloads over TCP to a collector server; Prometheus scrapes the collector's `/metrics` endpoint.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- `prometheus_exporter` in your Gemfile.
|
|
8
|
+
- A collector server reachable from every process that calls `require "lepus/prometheus"`. Typically this is the Lepus supervisor process itself, listening on `localhost:9394` (so forked workers can reach it without networking changes).
|
|
9
|
+
|
|
10
|
+
## Enabling instrumentation
|
|
11
|
+
|
|
12
|
+
In each process that runs Lepus (supervisor + workers), load:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
require "lepus/prometheus"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This prepends instrumentation onto `Lepus::Consumers::Handler` and `Lepus::Consumers::Worker` and subscribes to the `publish.lepus` `ActiveSupport::Notifications` event. Requiring must happen before the supervisor forks workers so every child inherits the instrumentation.
|
|
19
|
+
|
|
20
|
+
## Starting the collector server
|
|
21
|
+
|
|
22
|
+
Run the collector inside the supervisor so forked workers can send metrics to `localhost:9394`:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# config/initializers/lepus.rb
|
|
26
|
+
require "lepus/web"
|
|
27
|
+
|
|
28
|
+
if Rails.application.config.prometheus_enabled
|
|
29
|
+
require "lepus/prometheus"
|
|
30
|
+
require "lepus/prometheus/collector"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Lepus.configure do |config|
|
|
34
|
+
# …
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if Rails.application.config.prometheus_enabled
|
|
38
|
+
Lepus::Supervisor.on_start do
|
|
39
|
+
require "prometheus_exporter/server"
|
|
40
|
+
|
|
41
|
+
collector = PrometheusExporter::Server::Collector.new
|
|
42
|
+
collector.register_collector(Lepus::Prometheus::Collector.new)
|
|
43
|
+
|
|
44
|
+
server = PrometheusExporter::Server::WebServer.new(
|
|
45
|
+
port: 9394,
|
|
46
|
+
bind: "0.0.0.0",
|
|
47
|
+
collector: collector
|
|
48
|
+
)
|
|
49
|
+
server.start
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Point Prometheus at `<lepus-host>:9394` in your scrape config.
|
|
55
|
+
|
|
56
|
+
## Polling RabbitMQ queue stats
|
|
57
|
+
|
|
58
|
+
Queue depth is not published by the workers — it's pulled from the RabbitMQ Management API by a poller thread. Start it from `on_start`:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Lepus::Supervisor.on_start do
|
|
62
|
+
Lepus::Prometheus.watch_queues(interval: 30)
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The poller emits a `lepus_queue_poll_last_success_timestamp_seconds` gauge after each successful API round-trip and a `lepus_queue_poll_errors_total` counter on failure — alert on stale timestamps to catch a silently broken poller.
|
|
67
|
+
|
|
68
|
+
## Metrics
|
|
69
|
+
|
|
70
|
+
| Metric | Type | Labels |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `lepus_messages_processed_total` | counter | `consumer`, `queue`, `result` (`ack`/`reject`/`requeue`/`nack`/`error`), `error` (exception class, empty on success) |
|
|
73
|
+
| `lepus_delivery_duration_seconds` | histogram | `consumer`, `queue` |
|
|
74
|
+
| `lepus_messages_published_total` | counter | `exchange`, `routing_key` |
|
|
75
|
+
| `lepus_publish_duration_seconds` | histogram | `exchange`, `routing_key` |
|
|
76
|
+
| `lepus_process_rss_memory_bytes` | gauge | `kind`, `name` |
|
|
77
|
+
| `lepus_process_info` | gauge (always `1`) | `kind`, `name`, `pid`, `hostname` |
|
|
78
|
+
| `lepus_queue_messages` | gauge | `name` |
|
|
79
|
+
| `lepus_queue_messages_ready` | gauge | `name` |
|
|
80
|
+
| `lepus_queue_messages_unacknowledged` | gauge | `name` |
|
|
81
|
+
| `lepus_queue_consumers` | gauge | `name` |
|
|
82
|
+
| `lepus_queue_memory_bytes` | gauge | `name` |
|
|
83
|
+
| `lepus_queue_poll_last_success_timestamp_seconds` | gauge | — |
|
|
84
|
+
| `lepus_queue_poll_errors_total` | counter | `error` |
|
|
85
|
+
|
|
86
|
+
`result="error"` is recorded for every delivery that raises out of the consumer, alongside the exception class — queryable as `rate(lepus_messages_processed_total{result="error"}[5m])`.
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Lepus.configure do |config|
|
|
92
|
+
# Histogram buckets (in seconds) used for delivery and publish latency.
|
|
93
|
+
config.prometheus_buckets = [0.01, 0.05, 0.1, 0.5, 1, 5]
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
To send metrics to a collector on a different host:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
require "prometheus_exporter/client"
|
|
101
|
+
Lepus::Prometheus.client = PrometheusExporter::Client.new(host: "collector.internal", port: 9394)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Standalone collector mode
|
|
105
|
+
|
|
106
|
+
You can also run `prometheus_exporter` as a separate process and load just the collector:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
prometheus_exporter -a lepus/prometheus/collector
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`lib/lepus/prometheus/collector.rb` deliberately does not require the rest of the gem, so this works without a full Lepus boot.
|
data/docs/rails.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Rails Integration
|
|
2
|
+
|
|
3
|
+
When Rails is loaded, Lepus's Railtie wires up sensible defaults. No initializer is strictly required.
|
|
4
|
+
|
|
5
|
+
## What the Railtie does
|
|
6
|
+
|
|
7
|
+
- Sets `config.logger = Rails.logger` (unless you've already set one).
|
|
8
|
+
- Sets `config.app_executor = Rails.application.executor` — every consumer `perform` runs inside the Rails executor, which means:
|
|
9
|
+
- Autoloading works (Zeitwerk is active).
|
|
10
|
+
- Query cache is cleared after each message.
|
|
11
|
+
- Connection cleanup runs after each message.
|
|
12
|
+
- Reloading works in development.
|
|
13
|
+
- Subscribes the log subscriber for friendly production log lines.
|
|
14
|
+
- Integrates with `Rails.error` — unhandled exceptions are reported there.
|
|
15
|
+
|
|
16
|
+
## Overriding
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# config/initializers/lepus.rb
|
|
20
|
+
Lepus.configure do |config|
|
|
21
|
+
config.rabbitmq_url = ENV.fetch('RABBITMQ_URL')
|
|
22
|
+
config.connection_name = 'my-service'
|
|
23
|
+
|
|
24
|
+
# Override the defaults:
|
|
25
|
+
config.logger = MyLogger.new
|
|
26
|
+
config.app_executor = nil # disable executor wrapping entirely
|
|
27
|
+
|
|
28
|
+
config.consumers_directory = 'app/consumers'
|
|
29
|
+
|
|
30
|
+
config.worker(:default) do |w|
|
|
31
|
+
w.pool_size = 5
|
|
32
|
+
w.before_fork { ActiveRecord::Base.connection_handler.clear_all_connections! }
|
|
33
|
+
w.after_fork { ActiveRecord::Base.establish_connection }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Defining consumers and producers
|
|
39
|
+
|
|
40
|
+
Put them in `app/consumers/` and `app/producers/`. With `config.consumers_directory = 'app/consumers'`, `lepus start` with no arguments auto-loads all of them.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/consumers/orders_consumer.rb
|
|
44
|
+
class OrdersConsumer < Lepus::Consumer
|
|
45
|
+
configure(
|
|
46
|
+
queue: 'orders',
|
|
47
|
+
exchange: { name: 'orders', type: :topic, durable: true },
|
|
48
|
+
routing_key: 'order.*'
|
|
49
|
+
)
|
|
50
|
+
use :json, symbolize_keys: true
|
|
51
|
+
|
|
52
|
+
def perform(message)
|
|
53
|
+
Order.create!(message.payload)
|
|
54
|
+
:ack
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# app/producers/orders_producer.rb
|
|
61
|
+
class OrdersProducer < Lepus::Producer
|
|
62
|
+
configure(exchange: { name: 'orders', type: :topic, durable: true })
|
|
63
|
+
use :json
|
|
64
|
+
use :correlation_id
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Running alongside a Rails web app
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Terminal 1 — web
|
|
72
|
+
bin/rails server
|
|
73
|
+
|
|
74
|
+
# Terminal 2 — consumers
|
|
75
|
+
bundle exec lepus start --require_file config/environment.rb
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or use Foreman:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
# Procfile
|
|
82
|
+
web: bin/rails server
|
|
83
|
+
worker: bundle exec lepus start --require_file config/environment.rb
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## The Puma plugin
|
|
87
|
+
|
|
88
|
+
For apps that want consumers running inside the same Puma process (development convenience, or small-scale production where you don't want another service):
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config/puma.rb
|
|
92
|
+
plugin :lepus
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The plugin forks consumer workers as Puma's cluster workers would, tying their lifecycle to Puma's.
|
|
96
|
+
|
|
97
|
+
**Caution:** running consumers inside Puma means process restarts on deploy take longer (waiting for in-flight messages) and you can't scale consumers independently of web requests. For non-trivial workloads, run them as a separate service.
|
|
98
|
+
|
|
99
|
+
## Mounting the web dashboard
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# config/routes.rb
|
|
103
|
+
require 'lepus/web'
|
|
104
|
+
|
|
105
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
106
|
+
mount Lepus::Web::App, at: '/lepus'
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
See [web.md](web.md).
|
|
111
|
+
|
|
112
|
+
## Testing
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# spec/rails_helper.rb (or spec/spec_helper.rb)
|
|
116
|
+
require 'lepus/testing'
|
|
117
|
+
|
|
118
|
+
RSpec.configure do |config|
|
|
119
|
+
config.before(:each) { Lepus::Testing.enable!; Lepus::Testing.reset! }
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
See [testing.md](testing.md).
|
|
124
|
+
|
|
125
|
+
## Active Record gotchas
|
|
126
|
+
|
|
127
|
+
Since `app_executor` is set to `Rails.application.executor`, Active Record connection management is handled per message — no manual `clear_active_connections!` needed.
|
|
128
|
+
|
|
129
|
+
For worker subprocesses, the `before_fork` / `after_fork` hooks close and reopen connections cleanly:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Lepus.configure do |config|
|
|
133
|
+
config.worker(:default) do |w|
|
|
134
|
+
w.before_fork { ActiveRecord::Base.connection_handler.clear_all_connections! }
|
|
135
|
+
w.after_fork { ActiveRecord::Base.establish_connection }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Without these hooks, all children inherit the same database socket and things go badly fast.
|
|
141
|
+
|
|
142
|
+
## Exception reporting
|
|
143
|
+
|
|
144
|
+
Lepus's Rails integration reports unhandled exceptions via `Rails.error`:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# config/application.rb or an initializer
|
|
148
|
+
Rails.error.subscribe do |exception, handled:, severity:, context:, source:|
|
|
149
|
+
Honeybadger.notify(exception, context: context) if source == 'lepus'
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Or use the `:honeybadger` middleware directly on your consumers — see [middleware.md](middleware.md).
|
|
154
|
+
|
|
155
|
+
## Zeitwerk
|
|
156
|
+
|
|
157
|
+
Consumer and producer classes are autoloaded by Zeitwerk like any other Rails class. `app/consumers/orders_consumer.rb` → `OrdersConsumer`, namespaced with the usual folder-to-module rules.
|
|
158
|
+
|
|
159
|
+
## Generators
|
|
160
|
+
|
|
161
|
+
None at the moment. Create consumer and producer files by hand (or your editor's snippet of choice).
|