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/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).
|
data/docs/supervisor.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Supervisor
|
|
2
|
+
|
|
3
|
+
The supervisor is the parent process when you run `lepus start`. It forks workers, monitors them, and handles signals.
|
|
4
|
+
|
|
5
|
+
## Process model
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Supervisor (pid = $$)
|
|
9
|
+
├── Worker[:default] pid = 1001
|
|
10
|
+
│ ├── Thread OrdersConsumer
|
|
11
|
+
│ └── Thread OrdersConsumer
|
|
12
|
+
└── Worker[:high_priority] pid = 1002
|
|
13
|
+
└── Thread PaymentsConsumer
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **One worker per named pool.** Consumers with `process.name = :default` share a worker; consumers with `process.name = :high_priority` share a different one. Unnamed consumers default to `:default`.
|
|
17
|
+
- **Multiple threads per worker.** Each worker runs a thread pool sized by `config.worker(:name).pool_size`.
|
|
18
|
+
- **One channel per consumer.** Each consumer gets its own Bunny channel to avoid cross-consumer interference.
|
|
19
|
+
|
|
20
|
+
Fork boundaries matter because child processes inherit open file descriptors, sockets, and DB connections. See the `before_fork` / `after_fork` hooks below.
|
|
21
|
+
|
|
22
|
+
## Fork hooks
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
Lepus.configure do |config|
|
|
26
|
+
config.worker(:default) do |w|
|
|
27
|
+
# Runs in the SUPERVISOR just before fork()
|
|
28
|
+
w.before_fork do
|
|
29
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
30
|
+
Redis.current.disconnect!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Runs in the CHILD after fork()
|
|
34
|
+
w.after_fork do
|
|
35
|
+
ActiveRecord::Base.establish_connection
|
|
36
|
+
Redis.current = Redis.new
|
|
37
|
+
SecureRandom.hex(4) # implicit reseed
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The classic pattern: close long-lived connections in `before_fork`, reopen them in `after_fork`. Otherwise all children end up sharing the same socket.
|
|
44
|
+
|
|
45
|
+
## Signals
|
|
46
|
+
|
|
47
|
+
| Signal | Supervisor response |
|
|
48
|
+
|--------|---------------------|
|
|
49
|
+
| `SIGTERM` | Graceful shutdown — see below. |
|
|
50
|
+
| `SIGINT` | Graceful shutdown (same as SIGTERM). |
|
|
51
|
+
| `SIGQUIT` | Graceful shutdown, slightly more aggressive. |
|
|
52
|
+
| `SIGTTIN` | Dump thread backtraces of every worker (debugging). |
|
|
53
|
+
| `SIGUSR1` | Reopen log files (useful for logrotate). |
|
|
54
|
+
|
|
55
|
+
Child workers respond to the same signals, but normally the supervisor handles signals and forwards the shutdown to children.
|
|
56
|
+
|
|
57
|
+
## Graceful shutdown
|
|
58
|
+
|
|
59
|
+
1. Supervisor writes a shutdown message to each worker's pipe.
|
|
60
|
+
2. Each worker stops accepting new messages (`basic.cancel` on its channels).
|
|
61
|
+
3. In-flight `perform` calls are allowed to finish.
|
|
62
|
+
4. Workers close connections and exit with code 0.
|
|
63
|
+
5. If a worker takes longer than `shutdown_timeout` (default 5 seconds per message, tuned via consumer `channel.shutdown_timeout`), it's killed with `SIGKILL`.
|
|
64
|
+
6. Supervisor exits.
|
|
65
|
+
|
|
66
|
+
Rule of thumb for deploy scripts: send SIGTERM, wait at least `pool_size × average_message_duration + 5 seconds`, then the supervisor should have exited cleanly.
|
|
67
|
+
|
|
68
|
+
## Worker crash recovery
|
|
69
|
+
|
|
70
|
+
If a worker exits unexpectedly (raised exception outside a `perform` call, OOM, killed by OOM killer):
|
|
71
|
+
|
|
72
|
+
1. Supervisor detects the pipe close.
|
|
73
|
+
2. Logs the exit status.
|
|
74
|
+
3. Forks a new worker using the same `before_fork` / `after_fork` hooks.
|
|
75
|
+
4. Worker re-declares its queues and resumes consuming.
|
|
76
|
+
|
|
77
|
+
Within a `perform`, exceptions are caught and routed to `on_thread_error`; the worker is not restarted for those. Only crashes at the worker level (outside message handling) trigger a restart.
|
|
78
|
+
|
|
79
|
+
## Heartbeats and the process registry
|
|
80
|
+
|
|
81
|
+
Each worker (and the supervisor itself) emits heartbeats to the process registry:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
config.process_heartbeat_interval = 60 # seconds
|
|
85
|
+
config.process_alive_threshold = 5 * 60 # seconds
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The web dashboard reads from this registry to display process status. Processes whose last heartbeat is older than `process_alive_threshold` are considered dead and hidden from the UI (but kept briefly so transient restarts don't flash).
|
|
89
|
+
|
|
90
|
+
## Pidfile
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bundle exec lepus start --pidfile /var/run/lepus.pid
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Written by the supervisor at boot, removed at graceful shutdown. If the file exists when lepus starts, the supervisor checks if the PID is alive; if so, it exits (don't run two supervisors). If not, it overwrites.
|
|
97
|
+
|
|
98
|
+
## Running under a process manager
|
|
99
|
+
|
|
100
|
+
- **systemd:** `ExecStart=/path/to/bundle exec lepus start --pidfile /run/lepus.pid`, `KillSignal=SIGTERM`, `TimeoutStopSec=60`.
|
|
101
|
+
- **Foreman / Procfile:** `worker: bundle exec lepus start`. No pidfile needed.
|
|
102
|
+
- **Kubernetes:** set a `preStop` lifecycle hook to send SIGTERM, and tune `terminationGracePeriodSeconds` to be larger than your longest `perform`.
|
|
103
|
+
|
|
104
|
+
## Debugging a stuck worker
|
|
105
|
+
|
|
106
|
+
Send SIGTTIN:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
kill -TTIN <worker-pid>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The worker prints thread backtraces to the log. Useful when you suspect a deadlock or a `perform` stuck on a slow network call.
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Lepus ships a test-mode module that captures publishes and runs consumer `perform` methods synchronously — no RabbitMQ connection required.
|
|
4
|
+
|
|
5
|
+
## Enabling
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# spec/spec_helper.rb (RSpec)
|
|
9
|
+
require 'lepus/testing'
|
|
10
|
+
|
|
11
|
+
RSpec.configure do |config|
|
|
12
|
+
config.before(:each) { Lepus::Testing.enable! }
|
|
13
|
+
config.after(:each) { Lepus::Testing.reset! }
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Once enabled:
|
|
18
|
+
|
|
19
|
+
- Publishes don't hit RabbitMQ. They're captured in an in-memory buffer keyed by producer class.
|
|
20
|
+
- Consumer handling is synchronous when invoked through `Lepus::Testing.consumer_perform`.
|
|
21
|
+
|
|
22
|
+
## Testing a consumer
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
describe OrdersConsumer do
|
|
26
|
+
it 'creates an order' do
|
|
27
|
+
result = Lepus::Testing.consumer_perform(
|
|
28
|
+
OrdersConsumer,
|
|
29
|
+
{ order_id: 42, total: 99.99 }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(result).to eq(:ack)
|
|
33
|
+
expect(Order.find(42).total).to eq(99.99)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'rejects invalid payloads' do
|
|
37
|
+
result = Lepus::Testing.consumer_perform(OrdersConsumer, { bad: 'data' })
|
|
38
|
+
expect(result).to eq(:reject)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'sets delivery info and metadata' do
|
|
42
|
+
result = Lepus::Testing.consumer_perform(
|
|
43
|
+
OrdersConsumer,
|
|
44
|
+
{ order_id: 7 },
|
|
45
|
+
delivery_info: { routing_key: 'order.created' },
|
|
46
|
+
metadata: { correlation_id: 'abc-123' }
|
|
47
|
+
)
|
|
48
|
+
expect(result).to eq(:ack)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`consumer_perform` signature:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Lepus::Testing.consumer_perform(
|
|
57
|
+
ConsumerClass,
|
|
58
|
+
payload,
|
|
59
|
+
delivery_info: {},
|
|
60
|
+
metadata: {}
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It builds a `Lepus::Message`, runs the full middleware chain (including global middlewares), and returns the disposition symbol.
|
|
65
|
+
|
|
66
|
+
## Testing a producer
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
describe OrdersProducer do
|
|
70
|
+
it 'publishes the order' do
|
|
71
|
+
order = Order.create!(id: 42, total: 99.99)
|
|
72
|
+
OrdersProducer.order_created(order)
|
|
73
|
+
|
|
74
|
+
messages = Lepus::Testing.producer_messages(OrdersProducer)
|
|
75
|
+
expect(messages.size).to eq(1)
|
|
76
|
+
expect(messages[0][:payload]).to include(order_id: 42)
|
|
77
|
+
expect(messages[0][:routing_key]).to eq('order.created')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'runs through middleware' do
|
|
81
|
+
OrdersProducer.publish({ foo: 'bar' }, routing_key: 'x')
|
|
82
|
+
|
|
83
|
+
msg = Lepus::Testing.producer_messages(OrdersProducer).last
|
|
84
|
+
expect(msg[:metadata][:correlation_id]).to be_present # set by :correlation_id middleware
|
|
85
|
+
expect(msg[:metadata][:content_type]).to eq('application/json')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`producer_messages(ProducerClass)` returns an array of hashes with `:payload`, `:routing_key`, `:delivery_info`, `:metadata`.
|
|
91
|
+
|
|
92
|
+
## RSpec matchers
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# spec/spec_helper.rb
|
|
96
|
+
require 'lepus/testing/rspec_matchers'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
expect { OrdersProducer.order_created(order) }
|
|
103
|
+
.to have_published_message(OrdersProducer)
|
|
104
|
+
.with_payload(include(order_id: order.id))
|
|
105
|
+
.to_routing_key('order.created')
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Testing middleware in isolation
|
|
109
|
+
|
|
110
|
+
Middlewares are plain Ruby objects with a `call(message, app)` method. Unit-test them directly:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
describe LogLevelMiddleware do
|
|
114
|
+
it 'logs before calling down the chain' do
|
|
115
|
+
middleware = LogLevelMiddleware.new(level: :debug)
|
|
116
|
+
message = Lepus::Testing::MessageBuilder.build(payload: { x: 1 })
|
|
117
|
+
captured = nil
|
|
118
|
+
|
|
119
|
+
allow(Lepus.logger).to receive(:debug) { |msg| captured = msg }
|
|
120
|
+
middleware.call(message, ->(m) { :ack })
|
|
121
|
+
|
|
122
|
+
expect(captured).to include('Processing:')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`Lepus::Testing::MessageBuilder.build(**kwargs)` builds a realistic `Lepus::Message` for unit tests.
|
|
128
|
+
|
|
129
|
+
## Resetting between tests
|
|
130
|
+
|
|
131
|
+
`Lepus::Testing.reset!` clears captured publishes. In shared setup:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
RSpec.configure do |config|
|
|
135
|
+
config.before(:each) { Lepus::Testing.enable!; Lepus::Testing.reset! }
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## When you do need a real RabbitMQ
|
|
140
|
+
|
|
141
|
+
Integration tests that exercise the full round-trip (publish → RabbitMQ → consume) benefit from a real broker. Use `docker run rabbitmq:3-management` or your test infra's existing one, and skip `Lepus::Testing.enable!` for those specs.
|
data/docs/web.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Web Dashboard
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Lepus ships a Rack-based monitoring UI showing consumer status, throughput, and recent activity.
|
|
6
|
+
|
|
7
|
+
## Running it standalone
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle exec lepus web --port 9292 --host 0.0.0.0
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Visit http://localhost:9292.
|
|
14
|
+
|
|
15
|
+
## Mounting in Rails
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# config/routes.rb
|
|
19
|
+
require 'lepus/web'
|
|
20
|
+
|
|
21
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
22
|
+
mount Lepus::Web::App, at: '/lepus'
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or with Devise:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
authenticate :admin_user do
|
|
30
|
+
mount Lepus::Web::App, at: '/admin/lepus'
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Important:** the dashboard has no built-in auth. Wrap it with whatever your app already uses.
|
|
35
|
+
|
|
36
|
+
## What it shows
|
|
37
|
+
|
|
38
|
+
- **Supervisors.** Every running `lepus start` process.
|
|
39
|
+
- **Workers.** Subprocesses per supervisor, with their named pool and PID.
|
|
40
|
+
- **Consumers.** Per-consumer message counts (processed, rejected, errored), queue names, routing keys.
|
|
41
|
+
- **Exchanges & queues.** RabbitMQ topology as seen by the gem.
|
|
42
|
+
- **Recent activity.** Last N messages — timestamps, routing keys, dispositions.
|
|
43
|
+
|
|
44
|
+
## Registry backend
|
|
45
|
+
|
|
46
|
+
The dashboard reads from the process registry, configured at `Lepus.configure`:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
config.process_registry_backend = :file # single-host
|
|
50
|
+
# or
|
|
51
|
+
config.process_registry_backend = :rabbitmq # multi-host
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `:file` — metadata stored under `/tmp/lepus/...`. Works out of the box but only for a single host.
|
|
55
|
+
- `:rabbitmq` — metadata stored in RabbitMQ itself. Multiple `lepus start` processes across multiple hosts show up in one dashboard.
|
|
56
|
+
|
|
57
|
+
For `:rabbitmq`, also set:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
config.management_api_url = 'http://rabbitmq:15672'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Heartbeats
|
|
64
|
+
|
|
65
|
+
Each worker heartbeats into the registry every `config.process_heartbeat_interval` (default 60 seconds). Processes are considered "alive" if their last heartbeat is within `config.process_alive_threshold` (default 5 minutes).
|
|
66
|
+
|
|
67
|
+
## API endpoints
|
|
68
|
+
|
|
69
|
+
The dashboard exposes a minimal read-only JSON API at `/api/...` — the UI is a single-page app that consumes it. You can also consume the API directly for custom dashboards or alerting.
|
|
70
|
+
|
|
71
|
+
Endpoints include (subject to change):
|
|
72
|
+
|
|
73
|
+
- `GET /api/processes` — all tracked processes
|
|
74
|
+
- `GET /api/consumers` — all registered consumers with counts
|
|
75
|
+
- `GET /api/queues` — queue metadata from the RabbitMQ management API (if `management_api_url` is set)
|
|
76
|
+
|
|
77
|
+
## Prometheus metrics
|
|
78
|
+
|
|
79
|
+
See [prometheus.md](prometheus.md) for the metric list, label cardinality notes, and how to wire the collector server inside a Lepus supervisor process.
|
|
80
|
+
|
|
81
|
+
## Operating in production
|
|
82
|
+
|
|
83
|
+
- Put the dashboard behind your existing auth layer (OAuth proxy, Rails authentication, Basic auth).
|
|
84
|
+
- Use `:rabbitmq` registry backend for multi-node visibility.
|
|
85
|
+
- Retain logs separately — the dashboard is for live state, not audit trails.
|