lepus 0.0.1.rc2 → 0.1.1

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +17 -4
  5. data/README.md +179 -0
  6. data/config.ru +14 -0
  7. data/docs/README.md +80 -0
  8. data/docs/cli.md +108 -0
  9. data/docs/configuration.md +171 -0
  10. data/docs/consumers.md +168 -0
  11. data/docs/getting-started.md +136 -0
  12. data/docs/images/lepus-web.png +0 -0
  13. data/docs/middleware.md +240 -0
  14. data/docs/producers.md +173 -0
  15. data/docs/prometheus.md +112 -0
  16. data/docs/rails.md +161 -0
  17. data/docs/supervisor.md +112 -0
  18. data/docs/testing.md +141 -0
  19. data/docs/web.md +85 -0
  20. data/examples/grafana-dashboard.json +450 -0
  21. data/gemfiles/Gemfile.rails-5.2 +1 -0
  22. data/gemfiles/Gemfile.rails-5.2.lock +59 -46
  23. data/gemfiles/Gemfile.rails-6.1 +1 -0
  24. data/gemfiles/Gemfile.rails-6.1.lock +72 -58
  25. data/gemfiles/Gemfile.rails-7.2.lock +8 -1
  26. data/gemfiles/Gemfile.rails-8.0.lock +8 -1
  27. data/lepus.gemspec +5 -1
  28. data/lib/lepus/cli.rb +24 -0
  29. data/lib/lepus/configuration.rb +42 -0
  30. data/lib/lepus/consumer.rb +21 -1
  31. data/lib/lepus/consumers/handler.rb +3 -1
  32. data/lib/lepus/consumers/stats.rb +70 -0
  33. data/lib/lepus/consumers/stats_registry.rb +29 -0
  34. data/lib/lepus/consumers/worker.rb +7 -6
  35. data/lib/lepus/process.rb +4 -4
  36. data/lib/lepus/process_registry/backend.rb +49 -0
  37. data/lib/lepus/process_registry/file_backend.rb +108 -0
  38. data/lib/lepus/process_registry/message_builder.rb +72 -0
  39. data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
  40. data/lib/lepus/process_registry.rb +28 -67
  41. data/lib/lepus/prometheus/collector.rb +149 -0
  42. data/lib/lepus/prometheus/instrumentation.rb +168 -0
  43. data/lib/lepus/prometheus.rb +48 -0
  44. data/lib/lepus/publisher.rb +3 -1
  45. data/lib/lepus/supervisor.rb +9 -2
  46. data/lib/lepus/version.rb +1 -1
  47. data/lib/lepus/web/aggregator.rb +154 -0
  48. data/lib/lepus/web/api.rb +132 -0
  49. data/lib/lepus/web/app.rb +37 -0
  50. data/lib/lepus/web/management_api.rb +192 -0
  51. data/lib/lepus/web/respond_with.rb +28 -0
  52. data/lib/lepus/web.rb +238 -0
  53. data/lib/lepus.rb +5 -0
  54. data/test_offline.html +189 -0
  55. data/web/assets/css/styles.css +635 -0
  56. data/web/assets/js/app.js +6 -0
  57. data/web/assets/js/bootstrap.js +20 -0
  58. data/web/assets/js/controllers/connection_controller.js +44 -0
  59. data/web/assets/js/controllers/dashboard_controller.js +499 -0
  60. data/web/assets/js/controllers/queue_controller.js +17 -0
  61. data/web/assets/js/controllers/theme_controller.js +31 -0
  62. data/web/assets/js/offline-manager.js +233 -0
  63. data/web/assets/js/service-worker-manager.js +65 -0
  64. data/web/index.html +159 -0
  65. data/web/sw.js +144 -0
  66. 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
@@ -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.