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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Prometheus
|
|
7
|
+
# Hooks that run inside each Lepus process and forward metrics to the
|
|
8
|
+
# prometheus_exporter server via Lepus::Prometheus.emit.
|
|
9
|
+
module Instrumentation
|
|
10
|
+
# Tracks per-delivery outcomes and latency. Always emits a metric, even
|
|
11
|
+
# when the underlying consumer raises, so error rates are observable.
|
|
12
|
+
module HandlerExtensions
|
|
13
|
+
def process_delivery(delivery_info, metadata, payload)
|
|
14
|
+
start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
15
|
+
result = nil
|
|
16
|
+
error_class = nil
|
|
17
|
+
begin
|
|
18
|
+
result = super
|
|
19
|
+
rescue => e
|
|
20
|
+
error_class = e.class.name
|
|
21
|
+
raise
|
|
22
|
+
ensure
|
|
23
|
+
Lepus::Prometheus.emit(
|
|
24
|
+
:delivery,
|
|
25
|
+
consumer: @consumer_class.name,
|
|
26
|
+
queue: queue_name_for_metric,
|
|
27
|
+
result: error_class ? "error" : result.to_s,
|
|
28
|
+
error: error_class.to_s,
|
|
29
|
+
duration: ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def queue_name_for_metric
|
|
38
|
+
q = queue
|
|
39
|
+
q.respond_to?(:name) ? q.name : q.to_s
|
|
40
|
+
rescue
|
|
41
|
+
""
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Emits process-level gauges on each heartbeat tick. rss_memory is keyed
|
|
46
|
+
# on (kind, name) only to avoid leaking a new series per pid; pid and
|
|
47
|
+
# hostname stay available on the `lepus_process_info` info gauge.
|
|
48
|
+
module WorkerExtensions
|
|
49
|
+
def heartbeat
|
|
50
|
+
super
|
|
51
|
+
ensure
|
|
52
|
+
Lepus::Prometheus.emit(
|
|
53
|
+
:process,
|
|
54
|
+
kind: kind.to_s,
|
|
55
|
+
name: name,
|
|
56
|
+
rss_memory: safe_rss_memory_bytes
|
|
57
|
+
)
|
|
58
|
+
Lepus::Prometheus.emit(
|
|
59
|
+
:process_info,
|
|
60
|
+
kind: kind.to_s,
|
|
61
|
+
name: name,
|
|
62
|
+
pid: pid.to_s,
|
|
63
|
+
hostname: safe_hostname
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def safe_rss_memory_bytes
|
|
70
|
+
Lepus::Processes::MEMORY_GRABBER.call(pid) * 1024
|
|
71
|
+
rescue
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def safe_hostname
|
|
76
|
+
Socket.gethostname
|
|
77
|
+
rescue
|
|
78
|
+
""
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Periodic poller that turns RabbitMQ queue stats into gauge events.
|
|
83
|
+
# Runs in a single thread inside whichever process enabled it.
|
|
84
|
+
class QueuePoller
|
|
85
|
+
@thread = nil
|
|
86
|
+
@mutex = Mutex.new
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
def start(interval:, api:)
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
stop_locked
|
|
92
|
+
@thread = Thread.new { run_loop(interval, api) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stop
|
|
97
|
+
@mutex.synchronize { stop_locked }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def running?
|
|
101
|
+
@mutex.synchronize { !@thread.nil? && @thread.alive? }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def stop_locked
|
|
107
|
+
@thread&.kill
|
|
108
|
+
@thread = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_loop(interval, api)
|
|
112
|
+
loop do
|
|
113
|
+
poll_once(api)
|
|
114
|
+
sleep interval
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def poll_once(api)
|
|
119
|
+
api.queues.each do |q|
|
|
120
|
+
Lepus::Prometheus.emit(
|
|
121
|
+
:queue,
|
|
122
|
+
name: q[:name],
|
|
123
|
+
messages: q[:messages].to_i,
|
|
124
|
+
messages_ready: q[:messages_ready].to_i,
|
|
125
|
+
messages_unacknowledged: q[:messages_unacknowledged].to_i,
|
|
126
|
+
consumers: q[:consumers].to_i,
|
|
127
|
+
memory: q[:memory].to_i
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
Lepus::Prometheus.emit(:queue_poll, timestamp: Time.now.to_f)
|
|
131
|
+
rescue => e
|
|
132
|
+
Lepus::Prometheus.emit(:queue_poll_error, error: e.class.name)
|
|
133
|
+
Lepus.logger.warn("[Lepus::Prometheus] queue poll failed: #{e.message}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class << self
|
|
139
|
+
def install!
|
|
140
|
+
return if @installed
|
|
141
|
+
|
|
142
|
+
Lepus::Consumers::Handler.prepend(HandlerExtensions)
|
|
143
|
+
Lepus::Consumers::Worker.prepend(WorkerExtensions)
|
|
144
|
+
subscribe_publish_events
|
|
145
|
+
@installed = true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def subscribe_publish_events
|
|
151
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
152
|
+
|
|
153
|
+
ActiveSupport::Notifications.subscribe(/\Apublish\.lepus\z/) do |*args|
|
|
154
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
155
|
+
Lepus::Prometheus.emit(
|
|
156
|
+
:publish,
|
|
157
|
+
exchange: event.payload[:exchange].to_s,
|
|
158
|
+
routing_key: event.payload[:routing_key].to_s,
|
|
159
|
+
duration: event.duration / 1000.0
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Lepus::Prometheus::Instrumentation.install!
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prometheus_exporter"
|
|
4
|
+
require "prometheus_exporter/client"
|
|
5
|
+
|
|
6
|
+
require_relative "prometheus/instrumentation"
|
|
7
|
+
|
|
8
|
+
module Lepus
|
|
9
|
+
# Optional integration with the prometheus_exporter gem.
|
|
10
|
+
# Require "lepus/prometheus" in your Lepus process to start shipping
|
|
11
|
+
# metrics to a running prometheus_exporter server via the default client.
|
|
12
|
+
#
|
|
13
|
+
# On the prometheus_exporter server side, load the companion collector:
|
|
14
|
+
# prometheus_exporter -a lepus/prometheus/collector
|
|
15
|
+
module Prometheus
|
|
16
|
+
DEFAULT_QUEUE_POLL_INTERVAL = 30
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_writer :client
|
|
20
|
+
|
|
21
|
+
def client
|
|
22
|
+
@client ||= ::PrometheusExporter::Client.default
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Emit an opaque metric payload to the exporter server.
|
|
26
|
+
# Silently swallows transport errors so instrumentation cannot
|
|
27
|
+
# break the caller; non-transport bugs surface as debug logs.
|
|
28
|
+
def emit(metric, **data)
|
|
29
|
+
client.send_json(type: "lepus", metric: metric.to_s, **data)
|
|
30
|
+
rescue => e
|
|
31
|
+
Lepus.logger.debug { "[Lepus::Prometheus] emit(#{metric}) failed: #{e.class}: #{e.message}" }
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Start polling the RabbitMQ management API for queue-level gauges.
|
|
36
|
+
# Safe to call once per process. Requires "lepus/web/management_api".
|
|
37
|
+
def watch_queues(interval: DEFAULT_QUEUE_POLL_INTERVAL, api: nil)
|
|
38
|
+
require "lepus/web/management_api"
|
|
39
|
+
api ||= Lepus::Web::ManagementAPI.new
|
|
40
|
+
Instrumentation::QueuePoller.start(interval: interval, api: api)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop_watching_queues
|
|
44
|
+
Instrumentation::QueuePoller.stop
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_json"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
class Publisher
|
|
7
|
+
DEFAULT_EXCHANGE_OPTIONS = {
|
|
8
|
+
type: :topic,
|
|
9
|
+
durable: true,
|
|
10
|
+
auto_delete: false
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
DEFAULT_PUBLISH_OPTIONS = {
|
|
14
|
+
persistent: true
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# @param exchange_name [String] The name of the exchange to publish messages to.
|
|
18
|
+
# @param options [Hash] Additional options for the exchange (type, durable, auto_delete).
|
|
19
|
+
# @return [void]
|
|
20
|
+
def initialize(exchange_name, **options)
|
|
21
|
+
@exchange_name = exchange_name
|
|
22
|
+
@exchange_options = DEFAULT_EXCHANGE_OPTIONS.merge(options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def publish(message, **options)
|
|
26
|
+
return unless Producers.exchange_enabled?(exchange_name)
|
|
27
|
+
|
|
28
|
+
Lepus.config.producer_config.with_connection do |connection|
|
|
29
|
+
connection.with_channel do |channel|
|
|
30
|
+
channel_publish(channel, message, **options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param [Bunny::Channel] channel The channel to publish the message to.
|
|
36
|
+
# @param [String, Hash] message The message to publish.
|
|
37
|
+
# @param [Hash] options Additional options for the publish.
|
|
38
|
+
# @return [void]
|
|
39
|
+
def channel_publish(channel, message, **options)
|
|
40
|
+
raise ArgumentError, "channel is required" unless channel
|
|
41
|
+
return unless Producers.exchange_enabled?(exchange_name)
|
|
42
|
+
|
|
43
|
+
payload, opts = prepare_message(message, **options)
|
|
44
|
+
exchange = channel.exchange(exchange_name, exchange_options)
|
|
45
|
+
Lepus.instrument(:publish, exchange: exchange_name, routing_key: opts[:routing_key]) do
|
|
46
|
+
exchange.publish(payload, opts)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :exchange_name, :exchange_options
|
|
53
|
+
|
|
54
|
+
def prepare_message(message, **options)
|
|
55
|
+
opts = DEFAULT_PUBLISH_OPTIONS.merge(options)
|
|
56
|
+
payload = if message.is_a?(String)
|
|
57
|
+
opts[:content_type] ||= "text/plain"
|
|
58
|
+
message
|
|
59
|
+
else
|
|
60
|
+
opts[:content_type] ||= "application/json"
|
|
61
|
+
MultiJson.dump(message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
[payload, opts]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
class Supervisor < Processes::Base
|
|
5
|
+
module ChildrenPipes
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.send :include, InstanceMethods
|
|
8
|
+
base.class_eval do
|
|
9
|
+
after_shutdown :close_pipes
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module InstanceMethods
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def close_pipes
|
|
17
|
+
pipes.each_value do |pipe|
|
|
18
|
+
pipe.close if pipe && !pipe.closed?
|
|
19
|
+
end
|
|
20
|
+
@pipes = {}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
class Supervisor < Processes::Base
|
|
5
|
+
module LifecycleHooks
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend ClassMethods
|
|
8
|
+
base.send :include, InstanceMethods
|
|
9
|
+
base.instance_variable_set(:@lifecycle_hooks, {start: [], stop: []})
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
attr_reader :lifecycle_hooks
|
|
14
|
+
|
|
15
|
+
def on_start(&block)
|
|
16
|
+
lifecycle_hooks[:start] << block
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def on_stop(&block)
|
|
20
|
+
lifecycle_hooks[:stop] << block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear_hooks
|
|
24
|
+
lifecycle_hooks[:start] = []
|
|
25
|
+
lifecycle_hooks[:stop] = []
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module InstanceMethods
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run_start_hooks
|
|
33
|
+
run_hooks_for :start
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_stop_hooks
|
|
37
|
+
run_hooks_for :stop
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_hooks_for(event)
|
|
41
|
+
self.class.lifecycle_hooks.fetch(event, []).each do |block|
|
|
42
|
+
block.call
|
|
43
|
+
rescue Exception => exception # rubocop:disable Lint/RescueException
|
|
44
|
+
handle_thread_error(exception)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
class Supervisor < Processes::Base
|
|
5
|
+
module RegistryCleaner
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.send :include, InstanceMethods
|
|
8
|
+
base.class_eval do
|
|
9
|
+
after_shutdown :cleanup_registry
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module InstanceMethods
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def cleanup_registry
|
|
17
|
+
ProcessRegistry.stop
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/lepus/supervisor.rb
CHANGED
|
@@ -2,24 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module Lepus
|
|
4
4
|
class Supervisor < Processes::Base
|
|
5
|
+
SHUTDOWN_MSG = "shutdown"
|
|
6
|
+
|
|
5
7
|
include LifecycleHooks
|
|
8
|
+
include ChildrenPipes
|
|
6
9
|
include Maintenance
|
|
7
10
|
include Signals
|
|
8
11
|
include Pidfiled
|
|
12
|
+
include RegistryCleaner
|
|
9
13
|
|
|
10
14
|
class << self
|
|
11
15
|
def start(**options)
|
|
12
|
-
|
|
13
|
-
config = Config.new(**options)
|
|
14
|
-
new(config).tap(&:start)
|
|
16
|
+
new(**options).tap(&:start)
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
# @param require_file [String, nil] The file to require before loading consumers, typically the Rails environment file or similar.
|
|
21
|
+
# @param pidfile [String] The path to the pidfile where the supervisor's PID will be stored. Default is "tmp/pids/lepus.pid".
|
|
22
|
+
# @param shutdown_timeout [Integer] The timeout in seconds to wait for child processes to terminate gracefully before forcing termination. Default is 5 seconds.
|
|
23
|
+
# @param consumers [Array<String, Class>] An optional list of consumer class names (as strings or constants) to be run by this supervisor. If not provided, all discovered consumer classes will be used.
|
|
24
|
+
def initialize(require_file: nil, pidfile: "tmp/pids/lepus.pid", shutdown_timeout: 5, **kwargs)
|
|
25
|
+
@pidfile_path = pidfile
|
|
26
|
+
@require_file = require_file
|
|
27
|
+
@shutdown_timeout = shutdown_timeout.to_i
|
|
28
|
+
@consumer_class_names = Array(kwargs[:consumers]).map(&:to_s) if kwargs.key?(:consumers)
|
|
29
|
+
|
|
20
30
|
@forks = {}
|
|
31
|
+
@pipes = {}
|
|
21
32
|
@configured_processes = {}
|
|
22
|
-
|
|
33
|
+
|
|
34
|
+
super
|
|
35
|
+
|
|
36
|
+
@name ||= hostname
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def start
|
|
@@ -27,7 +41,7 @@ module Lepus
|
|
|
27
41
|
|
|
28
42
|
run_start_hooks
|
|
29
43
|
|
|
30
|
-
|
|
44
|
+
build_and_start_workers
|
|
31
45
|
launch_maintenance_task
|
|
32
46
|
|
|
33
47
|
supervise
|
|
@@ -35,17 +49,43 @@ module Lepus
|
|
|
35
49
|
|
|
36
50
|
def stop
|
|
37
51
|
super
|
|
52
|
+
|
|
38
53
|
run_stop_hooks
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
private
|
|
42
57
|
|
|
43
|
-
|
|
58
|
+
# @return [String] The raw location of the pidfile used to store the supervisor's `#pidfile`.
|
|
59
|
+
attr_reader :pidfile_path
|
|
60
|
+
|
|
61
|
+
# @return [String] The file to require before loading consumers, typically the Rails environment file or similar.
|
|
62
|
+
attr_reader :require_file
|
|
63
|
+
|
|
64
|
+
# @return [Integer] The timeout in seconds to wait for child processes to terminate gracefully before forcing termination.
|
|
65
|
+
attr_reader :shutdown_timeout
|
|
66
|
+
|
|
67
|
+
# @return [Hash{Integer[pid] => Lepus::Consumers::Worker}] map of forked process IDs to their instances
|
|
68
|
+
attr_reader :forks
|
|
69
|
+
|
|
70
|
+
# @return [Hash{Integer[pid] => Lepus::Consumers::WorkerFactory}] map of forked process IDs to their immutable factory configurations
|
|
71
|
+
attr_reader :configured_processes
|
|
72
|
+
|
|
73
|
+
# @return [Hash{Integer[pid] => IO}] map of forked process IDs to their communication pipes
|
|
74
|
+
attr_reader :pipes
|
|
75
|
+
|
|
76
|
+
# @return [Array<Lepus::Consumer>] the full list of consumer classes to be run by this supervisor and its child processes.
|
|
77
|
+
def consumer_classes
|
|
78
|
+
@consumer_classes ||= if @consumer_class_names
|
|
79
|
+
@consumer_class_names.map { |name| Lepus::Primitive::String.new(name).constantize }
|
|
80
|
+
else
|
|
81
|
+
Lepus::Consumer.descendants
|
|
82
|
+
end.reject(&:abstract_class?)
|
|
83
|
+
end
|
|
44
84
|
|
|
45
85
|
def boot
|
|
46
86
|
Lepus.instrument(:start_process, process: self) do
|
|
47
|
-
if
|
|
48
|
-
Kernel.require
|
|
87
|
+
if require_file
|
|
88
|
+
Kernel.require(require_file)
|
|
49
89
|
else
|
|
50
90
|
begin
|
|
51
91
|
require "rails"
|
|
@@ -56,6 +96,13 @@ module Lepus
|
|
|
56
96
|
end
|
|
57
97
|
end
|
|
58
98
|
|
|
99
|
+
# Start the registry *after* the host app is loaded. Mounting
|
|
100
|
+
# `Lepus::Web` in `routes.rb` (or any late `require "lepus/web"`) flips
|
|
101
|
+
# the default backend to :rabbitmq; starting the registry earlier
|
|
102
|
+
# would lock in the FileBackend and the later flip would point the
|
|
103
|
+
# registry at a backend that was never started.
|
|
104
|
+
ProcessRegistry.start
|
|
105
|
+
|
|
59
106
|
setup_consumers
|
|
60
107
|
check_bunny_connection
|
|
61
108
|
|
|
@@ -68,9 +115,24 @@ module Lepus
|
|
|
68
115
|
def setup_consumers
|
|
69
116
|
Lepus.eager_load_consumers!
|
|
70
117
|
|
|
71
|
-
if
|
|
118
|
+
if consumer_classes.empty?
|
|
72
119
|
abort "No consumers found. Exiting..."
|
|
73
120
|
end
|
|
121
|
+
|
|
122
|
+
consumer_classes.each do |consumer_class|
|
|
123
|
+
if consumer_class.config.nil?
|
|
124
|
+
abort <<~MSG
|
|
125
|
+
Consumer class #{klass} is not configured. Please use the `configure' class method
|
|
126
|
+
to set at least the queue name.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
|
|
130
|
+
class MyConsumer < Lepus::Consumer
|
|
131
|
+
configure queue: "my_queue"
|
|
132
|
+
end
|
|
133
|
+
MSG
|
|
134
|
+
end
|
|
135
|
+
end
|
|
74
136
|
end
|
|
75
137
|
|
|
76
138
|
def check_bunny_connection
|
|
@@ -78,9 +140,10 @@ module Lepus
|
|
|
78
140
|
temp_bunny.close
|
|
79
141
|
end
|
|
80
142
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
143
|
+
def build_and_start_workers
|
|
144
|
+
consumer_classes.group_by { |klass| klass.config.worker_name }.map do |worker_name, classes|
|
|
145
|
+
frozen_factory = Lepus::Consumers::WorkerFactory.immutate_with(worker_name, consumers: classes)
|
|
146
|
+
start_process(frozen_factory)
|
|
84
147
|
end
|
|
85
148
|
end
|
|
86
149
|
|
|
@@ -92,6 +155,7 @@ module Lepus
|
|
|
92
155
|
process_signal_queue
|
|
93
156
|
|
|
94
157
|
unless stopped?
|
|
158
|
+
check_for_shutdown_messages
|
|
95
159
|
reap_and_replace_terminated_forks
|
|
96
160
|
interruptible_sleep(1)
|
|
97
161
|
end
|
|
@@ -100,32 +164,40 @@ module Lepus
|
|
|
100
164
|
shutdown
|
|
101
165
|
end
|
|
102
166
|
|
|
103
|
-
def start_process(
|
|
104
|
-
process_instance =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
end
|
|
167
|
+
def start_process(factory)
|
|
168
|
+
process_instance = factory.instantiate_process
|
|
169
|
+
process_instance.supervised_by(process)
|
|
170
|
+
process_instance.mode = :fork
|
|
108
171
|
|
|
172
|
+
reader, writer = IO.pipe
|
|
109
173
|
process_instance.before_fork
|
|
110
174
|
pid = fork do
|
|
111
|
-
|
|
112
|
-
|
|
175
|
+
reader.close
|
|
176
|
+
begin
|
|
177
|
+
process_instance.after_fork
|
|
178
|
+
process_instance.start
|
|
179
|
+
rescue Lepus::ShutdownError
|
|
180
|
+
writer.puts(SHUTDOWN_MSG)
|
|
181
|
+
raise
|
|
182
|
+
ensure
|
|
183
|
+
writer.close
|
|
184
|
+
end
|
|
113
185
|
end
|
|
114
186
|
|
|
115
|
-
configured_processes[pid] =
|
|
187
|
+
configured_processes[pid] = factory
|
|
116
188
|
forks[pid] = process_instance
|
|
189
|
+
pipes[pid] = reader
|
|
117
190
|
end
|
|
118
191
|
|
|
119
192
|
def set_procline
|
|
120
|
-
procline "supervising #{supervised_processes.join(", ")}"
|
|
193
|
+
procline "#{kind.downcase}: supervising #{supervised_processes.join(", ")}"
|
|
121
194
|
end
|
|
122
195
|
|
|
123
196
|
def terminate_gracefully
|
|
124
197
|
Lepus.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload|
|
|
125
198
|
term_forks
|
|
126
199
|
|
|
127
|
-
shutdown_timeout
|
|
128
|
-
puts "\nWaiting up to #{shutdown_timeout} seconds for processes to terminate gracefully..."
|
|
200
|
+
# puts "\nWaiting up to #{shutdown_timeout} seconds for processes to terminate gracefully..."
|
|
129
201
|
Timer.wait_until(shutdown_timeout, -> { all_forks_terminated? }) do
|
|
130
202
|
reap_terminated_forks
|
|
131
203
|
end
|
|
@@ -167,6 +239,24 @@ module Lepus
|
|
|
167
239
|
signal_processes(forks.keys, :QUIT)
|
|
168
240
|
end
|
|
169
241
|
|
|
242
|
+
def check_for_shutdown_messages
|
|
243
|
+
open_pipes = pipes.values.reject(&:closed?)
|
|
244
|
+
return if open_pipes.empty?
|
|
245
|
+
|
|
246
|
+
# Check if any pipe has data available to read without blocking
|
|
247
|
+
ready_pipes, = IO.select(open_pipes, nil, nil, 0)
|
|
248
|
+
return unless ready_pipes
|
|
249
|
+
|
|
250
|
+
ready_pipes.each do |pipe|
|
|
251
|
+
message = pipe.gets&.chomp
|
|
252
|
+
initiate_shutdown_sequence_from_child(pipe) if message == SHUTDOWN_MSG
|
|
253
|
+
rescue IOError, Errno::EPIPE
|
|
254
|
+
# Pipe was closed or broken, clean it up
|
|
255
|
+
end
|
|
256
|
+
rescue IOError
|
|
257
|
+
# Handle any IO errors during select
|
|
258
|
+
end
|
|
259
|
+
|
|
170
260
|
def reap_and_replace_terminated_forks
|
|
171
261
|
loop do
|
|
172
262
|
pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
|
|
@@ -181,6 +271,8 @@ module Lepus
|
|
|
181
271
|
pid, _ = ::Process.waitpid2(-1, ::Process::WNOHANG)
|
|
182
272
|
break unless pid
|
|
183
273
|
|
|
274
|
+
pipes.delete(pid)&.close
|
|
275
|
+
forks.delete(pid)
|
|
184
276
|
configured_processes.delete(pid)
|
|
185
277
|
end
|
|
186
278
|
rescue SystemCallError
|
|
@@ -189,6 +281,7 @@ module Lepus
|
|
|
189
281
|
|
|
190
282
|
def replace_fork(pid, status)
|
|
191
283
|
Lepus.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
|
|
284
|
+
pipes.delete(pid)&.close
|
|
192
285
|
if (terminated_fork = forks.delete(pid))
|
|
193
286
|
payload[:fork] = terminated_fork
|
|
194
287
|
|
|
@@ -200,5 +293,16 @@ module Lepus
|
|
|
200
293
|
def all_forks_terminated?
|
|
201
294
|
forks.empty?
|
|
202
295
|
end
|
|
296
|
+
|
|
297
|
+
def initiate_shutdown_sequence_from_child(pipe)
|
|
298
|
+
if (pid = pipes.key(pipe))
|
|
299
|
+
pipes.delete(pid)
|
|
300
|
+
forks.delete(pid)
|
|
301
|
+
configured_processes.delete(pid)
|
|
302
|
+
end
|
|
303
|
+
pipe.close
|
|
304
|
+
quit_forks
|
|
305
|
+
stop
|
|
306
|
+
end
|
|
203
307
|
end
|
|
204
308
|
end
|