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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/linter.yml +21 -0
  3. data/.github/workflows/specs.yml +93 -13
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +10 -0
  6. data/.tool-versions +1 -1
  7. data/Gemfile +7 -0
  8. data/Gemfile.lock +36 -9
  9. data/Makefile +19 -0
  10. data/README.md +562 -7
  11. data/bin/setup +5 -2
  12. data/config.ru +14 -0
  13. data/docker-compose.yml +5 -3
  14. data/docs/README.md +80 -0
  15. data/docs/cli.md +108 -0
  16. data/docs/configuration.md +171 -0
  17. data/docs/consumers.md +168 -0
  18. data/docs/getting-started.md +136 -0
  19. data/docs/images/lepus-web.png +0 -0
  20. data/docs/middleware.md +240 -0
  21. data/docs/producers.md +173 -0
  22. data/docs/prometheus.md +112 -0
  23. data/docs/rails.md +161 -0
  24. data/docs/supervisor.md +112 -0
  25. data/docs/testing.md +141 -0
  26. data/docs/web.md +85 -0
  27. data/examples/grafana-dashboard.json +450 -0
  28. data/gemfiles/Gemfile.rails-5.2 +7 -0
  29. data/gemfiles/{rails52.gemfile.lock → Gemfile.rails-5.2.lock} +102 -69
  30. data/gemfiles/Gemfile.rails-6.1 +7 -0
  31. data/gemfiles/{rails61.gemfile.lock → Gemfile.rails-6.1.lock} +113 -79
  32. data/gemfiles/{rails52.gemfile → Gemfile.rails-7.2} +1 -1
  33. data/gemfiles/Gemfile.rails-7.2.lock +321 -0
  34. data/gemfiles/{rails61.gemfile → Gemfile.rails-8.0} +1 -1
  35. data/gemfiles/Gemfile.rails-8.0.lock +322 -0
  36. data/lepus.gemspec +7 -1
  37. data/lib/lepus/cli.rb +35 -4
  38. data/lib/lepus/configuration.rb +107 -0
  39. data/lib/lepus/connection_pool.rb +135 -0
  40. data/lib/lepus/consumer.rb +59 -41
  41. data/lib/lepus/consumers/config.rb +183 -0
  42. data/lib/lepus/consumers/handler.rb +56 -0
  43. data/lib/lepus/consumers/middleware_chain.rb +22 -0
  44. data/lib/lepus/consumers/middlewares/exception_logger.rb +27 -0
  45. data/lib/lepus/consumers/middlewares/honeybadger.rb +33 -0
  46. data/lib/lepus/consumers/middlewares/json.rb +37 -0
  47. data/lib/lepus/consumers/middlewares/max_retry.rb +83 -0
  48. data/lib/lepus/consumers/middlewares/unique.rb +65 -0
  49. data/lib/lepus/consumers/stats.rb +70 -0
  50. data/lib/lepus/consumers/stats_registry.rb +29 -0
  51. data/lib/lepus/consumers/worker.rb +141 -0
  52. data/lib/lepus/consumers/worker_factory.rb +124 -0
  53. data/lib/lepus/consumers.rb +6 -0
  54. data/lib/lepus/message/delivery_info.rb +72 -0
  55. data/lib/lepus/message/metadata.rb +99 -0
  56. data/lib/lepus/message.rb +88 -5
  57. data/lib/lepus/middleware_chain.rb +83 -0
  58. data/lib/lepus/primitive/hash.rb +29 -0
  59. data/lib/lepus/process.rb +24 -24
  60. data/lib/lepus/process_registry/backend.rb +49 -0
  61. data/lib/lepus/process_registry/file_backend.rb +108 -0
  62. data/lib/lepus/process_registry/message_builder.rb +72 -0
  63. data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
  64. data/lib/lepus/process_registry.rb +56 -23
  65. data/lib/lepus/processes/base.rb +0 -5
  66. data/lib/lepus/processes/callbacks.rb +3 -0
  67. data/lib/lepus/processes/interruptible.rb +4 -8
  68. data/lib/lepus/processes/procline.rb +1 -1
  69. data/lib/lepus/processes/registrable.rb +1 -1
  70. data/lib/lepus/processes/runnable.rb +1 -1
  71. data/lib/lepus/processes.rb +15 -0
  72. data/lib/lepus/producer.rb +141 -30
  73. data/lib/lepus/producers/config.rb +46 -0
  74. data/lib/lepus/producers/definition.rb +48 -0
  75. data/lib/lepus/producers/hooks.rb +170 -0
  76. data/lib/lepus/producers/middleware_chain.rb +22 -0
  77. data/lib/lepus/producers/middlewares/correlation_id.rb +37 -0
  78. data/lib/lepus/producers/middlewares/header.rb +47 -0
  79. data/lib/lepus/producers/middlewares/instrumentation.rb +30 -0
  80. data/lib/lepus/producers/middlewares/json.rb +47 -0
  81. data/lib/lepus/producers/middlewares/unique.rb +67 -0
  82. data/lib/lepus/producers.rb +7 -0
  83. data/lib/lepus/prometheus/collector.rb +149 -0
  84. data/lib/lepus/prometheus/instrumentation.rb +168 -0
  85. data/lib/lepus/prometheus.rb +48 -0
  86. data/lib/lepus/publisher.rb +67 -0
  87. data/lib/lepus/supervisor/children_pipes.rb +25 -0
  88. data/lib/lepus/supervisor/lifecycle_hooks.rb +50 -0
  89. data/lib/lepus/supervisor/pidfiled.rb +1 -1
  90. data/lib/lepus/supervisor/registry_cleaner.rb +22 -0
  91. data/lib/lepus/supervisor.rb +129 -25
  92. data/lib/lepus/testing/exchange.rb +95 -0
  93. data/lib/lepus/testing/message_builder.rb +177 -0
  94. data/lib/lepus/testing/rspec_matchers.rb +258 -0
  95. data/lib/lepus/testing.rb +210 -0
  96. data/lib/lepus/unique.rb +18 -0
  97. data/lib/lepus/version.rb +1 -1
  98. data/lib/lepus/web/aggregator.rb +154 -0
  99. data/lib/lepus/web/api.rb +132 -0
  100. data/lib/lepus/web/app.rb +37 -0
  101. data/lib/lepus/web/management_api.rb +192 -0
  102. data/lib/lepus/web/respond_with.rb +28 -0
  103. data/lib/lepus/web.rb +238 -0
  104. data/lib/lepus.rb +39 -28
  105. data/test_offline.html +189 -0
  106. data/web/assets/css/styles.css +635 -0
  107. data/web/assets/js/app.js +6 -0
  108. data/web/assets/js/bootstrap.js +20 -0
  109. data/web/assets/js/controllers/connection_controller.js +44 -0
  110. data/web/assets/js/controllers/dashboard_controller.js +499 -0
  111. data/web/assets/js/controllers/queue_controller.js +17 -0
  112. data/web/assets/js/controllers/theme_controller.js +31 -0
  113. data/web/assets/js/offline-manager.js +233 -0
  114. data/web/assets/js/service-worker-manager.js +65 -0
  115. data/web/index.html +159 -0
  116. data/web/sw.js +144 -0
  117. metadata +177 -18
  118. data/lib/lepus/consumer_config.rb +0 -149
  119. data/lib/lepus/consumer_wrapper.rb +0 -46
  120. data/lib/lepus/lifecycle_hooks.rb +0 -49
  121. data/lib/lepus/middlewares/honeybadger.rb +0 -23
  122. data/lib/lepus/middlewares/json.rb +0 -35
  123. data/lib/lepus/middlewares/max_retry.rb +0 -57
  124. data/lib/lepus/processes/consumer.rb +0 -113
  125. data/lib/lepus/supervisor/config.rb +0 -45
@@ -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
+ ![Lepus web dashboard](images/lepus-web.png)
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.