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,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
@@ -15,7 +15,7 @@ module Lepus
15
15
  private
16
16
 
17
17
  def setup_pidfile
18
- if (path = configuration.pidfile)
18
+ if (path = pidfile_path)
19
19
  @pidfile = Pidfile.new(path).tap(&:setup)
20
20
  end
21
21
  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
@@ -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
- # Lepus.config.supervisor = true
13
- config = Config.new(**options)
14
- new(config).tap(&:start)
16
+ new(**options).tap(&:start)
15
17
  end
16
18
  end
17
19
 
18
- def initialize(configuration)
19
- @configuration = configuration
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
- ProcessRegistry.instance # Ensure the registry is initialized
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
- start_processes
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
- attr_reader :configuration, :forks, :configured_processes
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 configuration.require_file
48
- Kernel.require configuration.require_file
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 configuration.consumers.empty?
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 start_processes
82
- configuration.configured_processes.each do |configured_process|
83
- start_process(configured_process)
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(configured_process)
104
- process_instance = configured_process.instantiate.tap do |instance|
105
- instance.supervised_by process
106
- instance.mode = :fork
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
- process_instance.after_fork
112
- process_instance.start
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] = configured_process
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 = 5
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