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
data/lib/lepus/cli.rb
CHANGED
|
@@ -2,26 +2,57 @@ require "thor"
|
|
|
2
2
|
|
|
3
3
|
module Lepus
|
|
4
4
|
class CLI < Thor
|
|
5
|
+
def self.exit_on_failure?
|
|
6
|
+
true
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
method_option :debug, type: :boolean, default: false
|
|
6
10
|
method_option :logfile, type: :string, default: nil
|
|
7
11
|
method_option :pidfile, type: :string, default: nil
|
|
8
12
|
method_option :require_file, type: :string, aliases: "-r", default: nil
|
|
9
13
|
|
|
10
|
-
desc "start FirstConsumer
|
|
14
|
+
desc "start FirstConsumer SecondConsumer ... NthConsumer", "Run Consumer"
|
|
11
15
|
default_command :start
|
|
12
16
|
|
|
13
|
-
def start(consumers
|
|
17
|
+
def start(*consumers)
|
|
14
18
|
opts = (@options || {}).transform_keys(&:to_sym)
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
if (list = consumers.flat_map { |c| c.split(",") }.map(&:strip).uniq.sort).any?
|
|
21
|
+
opts[:consumers] = list
|
|
17
22
|
end
|
|
23
|
+
|
|
18
24
|
if (logfile = opts.delete(:logfile))
|
|
19
25
|
Lepus.logger = Logger.new(logfile)
|
|
20
26
|
end
|
|
21
27
|
if opts.delete(:debug)
|
|
22
28
|
Lepus.logger.level = Logger::DEBUG
|
|
23
29
|
end
|
|
30
|
+
|
|
24
31
|
Lepus::Supervisor.start(**opts)
|
|
25
32
|
end
|
|
33
|
+
|
|
34
|
+
desc "web", "Run Lepus Web dashboard"
|
|
35
|
+
method_option :port, type: :numeric, aliases: "-p", default: 9292, desc: "Port to listen on"
|
|
36
|
+
method_option :host, type: :string, aliases: "-o", default: "0.0.0.0", desc: "Host to bind"
|
|
37
|
+
def web
|
|
38
|
+
port = (options[:port] || 9292).to_i
|
|
39
|
+
host = options[:host] || "0.0.0.0"
|
|
40
|
+
|
|
41
|
+
puts "Starting Lepus Web dashboard on http://#{host}:#{port}"
|
|
42
|
+
puts "Press Ctrl+C to stop"
|
|
43
|
+
|
|
44
|
+
if system("which rackup > /dev/null 2>&1")
|
|
45
|
+
|
|
46
|
+
exec "rackup -p #{port} -o #{host} #{__dir__}/../../config.ru"
|
|
47
|
+
else
|
|
48
|
+
puts <<~MSG
|
|
49
|
+
Rack is not installed. Please install it using the following command:
|
|
50
|
+
|
|
51
|
+
gem install rack
|
|
52
|
+
|
|
53
|
+
Then run the web dashboard again.
|
|
54
|
+
MSG
|
|
55
|
+
end
|
|
56
|
+
end
|
|
26
57
|
end
|
|
27
58
|
end
|
data/lib/lepus/configuration.rb
CHANGED
|
@@ -40,6 +40,27 @@ module Lepus
|
|
|
40
40
|
# @return [Integer] the threshold in seconds to consider a process alive. Default is 5 minutes.
|
|
41
41
|
attr_accessor :process_alive_threshold
|
|
42
42
|
|
|
43
|
+
# @return [Symbol] the process registry backend to use (:file or :rabbitmq). Default is :file.
|
|
44
|
+
attr_accessor :process_registry_backend
|
|
45
|
+
|
|
46
|
+
# @return [String, nil] the application name shown in the web dashboard.
|
|
47
|
+
# Falls back to {#connection_name} when not explicitly set so that hosts
|
|
48
|
+
# that only configure `connection_name` still group correctly in the UI.
|
|
49
|
+
attr_writer :application_name
|
|
50
|
+
|
|
51
|
+
def application_name
|
|
52
|
+
@application_name || connection_name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] the RabbitMQ Management API URL.
|
|
56
|
+
attr_accessor :management_api_url
|
|
57
|
+
|
|
58
|
+
# @return [Array<Numeric>] histogram buckets (in seconds) used by the
|
|
59
|
+
# prometheus_exporter collector for delivery and publish latency.
|
|
60
|
+
attr_accessor :prometheus_buckets
|
|
61
|
+
|
|
62
|
+
DEFAULT_PROMETHEUS_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
|
|
63
|
+
|
|
43
64
|
def initialize
|
|
44
65
|
@connection_name = "Lepus (#{Lepus::VERSION})"
|
|
45
66
|
@rabbitmq_url = ENV.fetch("RABBITMQ_URL", DEFAULT_RABBITMQ_URL) || DEFAULT_RABBITMQ_URL
|
|
@@ -49,22 +70,108 @@ module Lepus
|
|
|
49
70
|
@consumers_directory = DEFAULT_CONSUMERS_DIRECTORY
|
|
50
71
|
@process_heartbeat_interval = 60
|
|
51
72
|
@process_alive_threshold = 5 * 60
|
|
73
|
+
@process_registry_backend = :file
|
|
74
|
+
@application_name = nil
|
|
75
|
+
@management_api_url = nil
|
|
76
|
+
@prometheus_buckets = DEFAULT_PROMETHEUS_BUCKETS
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Builds the process registry backend based on configuration.
|
|
80
|
+
# @return [Lepus::ProcessRegistry::Backend] the configured backend
|
|
81
|
+
def build_process_registry_backend
|
|
82
|
+
case process_registry_backend
|
|
83
|
+
when :rabbitmq
|
|
84
|
+
ProcessRegistry::RabbitmqBackend.new
|
|
85
|
+
else
|
|
86
|
+
ProcessRegistry::FileBackend.new
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Builds the Management API client based on configuration.
|
|
91
|
+
# @return [Lepus::Web::ManagementAPI] the management API client
|
|
92
|
+
def build_management_api
|
|
93
|
+
Web::ManagementAPI.new(base_url: management_api_url)
|
|
52
94
|
end
|
|
53
95
|
|
|
96
|
+
# @param suffix [String] the suffix to add to the connection name
|
|
97
|
+
# @return [Bunny::Session] the connection to RabbitMQ
|
|
54
98
|
def create_connection(suffix: nil)
|
|
55
99
|
kwargs = connection_config
|
|
100
|
+
|
|
56
101
|
if suffix && connection_name
|
|
57
102
|
kwargs[:connection_name] = "#{connection_name} #{suffix}"
|
|
58
103
|
end
|
|
104
|
+
|
|
59
105
|
::Bunny
|
|
60
106
|
.new(rabbitmq_url, **kwargs)
|
|
61
107
|
.tap { |conn| conn.start }
|
|
62
108
|
end
|
|
63
109
|
|
|
110
|
+
# @param value [Pathname] the directory where the consumers are stored.
|
|
64
111
|
def consumers_directory=(value)
|
|
65
112
|
@consumers_directory = value.is_a?(Pathname) ? value : Pathname.new(value)
|
|
66
113
|
end
|
|
67
114
|
|
|
115
|
+
# Configure the worker process that will run the consumers.
|
|
116
|
+
# @param names [Array<Symbol>] the names of the workers to configure
|
|
117
|
+
# @param options [Hash] the options to assign to the worker configuration
|
|
118
|
+
def worker(*names, **options)
|
|
119
|
+
names << Lepus::Consumers::WorkerFactory::DEFAULT_NAME if names.empty?
|
|
120
|
+
|
|
121
|
+
names.map(&:to_s).uniq.each do |pid|
|
|
122
|
+
inst = Lepus::Consumers::WorkerFactory[pid]
|
|
123
|
+
inst.assign(options) if options.any?
|
|
124
|
+
yield(inst) if block_given?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Configure the producer related settings.
|
|
129
|
+
# @param options [Hash] the options to assign to the producer configuration
|
|
130
|
+
def producer(**options)
|
|
131
|
+
producer_config.assign(options) if options.any?
|
|
132
|
+
yield(producer_config) if block_given?
|
|
133
|
+
producer_config
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Lepus::Producers::Config] the producer configuration
|
|
137
|
+
def producer_config
|
|
138
|
+
@producer_config ||= Lepus::Producers::Config.new
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Lepus::Producers::MiddlewareChain] the global producer middleware chain
|
|
142
|
+
def producer_middleware_chain
|
|
143
|
+
@producer_middleware_chain ||= Lepus::Producers::MiddlewareChain.new
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Configure global producer middlewares.
|
|
147
|
+
# @yield [chain] Block to configure the middleware chain.
|
|
148
|
+
# @yieldparam chain [Lepus::Producers::MiddlewareChain] The middleware chain.
|
|
149
|
+
# @return [Lepus::Producers::MiddlewareChain]
|
|
150
|
+
def producer_middlewares
|
|
151
|
+
yield(producer_middleware_chain) if block_given?
|
|
152
|
+
producer_middleware_chain
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [Lepus::Consumers::MiddlewareChain] the global consumer middleware chain
|
|
156
|
+
def consumer_middleware_chain
|
|
157
|
+
@consumer_middleware_chain ||= Lepus::Consumers::MiddlewareChain.new
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Configure global consumer middlewares.
|
|
161
|
+
# @yield [chain] Block to configure the middleware chain.
|
|
162
|
+
# @yieldparam chain [Lepus::Consumers::MiddlewareChain] The middleware chain.
|
|
163
|
+
# @return [Lepus::Consumers::MiddlewareChain]
|
|
164
|
+
def consumer_middlewares
|
|
165
|
+
yield(consumer_middleware_chain) if block_given?
|
|
166
|
+
consumer_middleware_chain
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param value [Logger] the logger to set
|
|
170
|
+
# @return [void]
|
|
171
|
+
def logger=(value)
|
|
172
|
+
Lepus.logger = value
|
|
173
|
+
end
|
|
174
|
+
|
|
68
175
|
protected
|
|
69
176
|
|
|
70
177
|
def connection_config
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
# Connection pool for managing Bunny connections efficiently
|
|
7
|
+
# Similar to the connection_pool gem but using concurrent-ruby primitives
|
|
8
|
+
class ConnectionPool
|
|
9
|
+
DEFAULT_SIZE = 5
|
|
10
|
+
DEFAULT_TIMEOUT = 5.0
|
|
11
|
+
|
|
12
|
+
attr_reader :pool_size, :timeout, :conn_suffix
|
|
13
|
+
|
|
14
|
+
def initialize(size: DEFAULT_SIZE, timeout: DEFAULT_TIMEOUT, suffix: nil)
|
|
15
|
+
@pool_size = size
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
@conn_suffix = suffix
|
|
18
|
+
@available = Concurrent::Array.new
|
|
19
|
+
@in_use = Concurrent::Array.new
|
|
20
|
+
@semaphore = Concurrent::Semaphore.new(pool_size)
|
|
21
|
+
@mutex = Concurrent::ReadWriteLock.new
|
|
22
|
+
@shutdown = Concurrent::AtomicBoolean.new(false)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_connection
|
|
26
|
+
connection = checkout
|
|
27
|
+
begin
|
|
28
|
+
yield connection
|
|
29
|
+
ensure
|
|
30
|
+
checkin(connection)
|
|
31
|
+
end
|
|
32
|
+
rescue Concurrent::TimeoutError
|
|
33
|
+
raise Lepus::ConnectionPoolTimeoutError, "Connection pool timeout after #{timeout} seconds"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def checkout
|
|
37
|
+
raise Lepus::ConnectionPoolError, "Connection pool is shut down" if @shutdown.value
|
|
38
|
+
|
|
39
|
+
# Try to acquire a permit with timeout
|
|
40
|
+
start_time = Time.now
|
|
41
|
+
acquired = false
|
|
42
|
+
|
|
43
|
+
while Time.now - start_time < timeout
|
|
44
|
+
if @semaphore.try_acquire
|
|
45
|
+
acquired = true
|
|
46
|
+
break
|
|
47
|
+
end
|
|
48
|
+
sleep(0.01) # Small sleep to avoid busy waiting
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unless acquired
|
|
52
|
+
raise Concurrent::TimeoutError, "Connection pool timeout"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@mutex.with_read_lock do
|
|
56
|
+
# Try to reuse an existing connection
|
|
57
|
+
connection = @available.shift
|
|
58
|
+
if connection&.connected?
|
|
59
|
+
@in_use << connection
|
|
60
|
+
return connection
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Create a new connection
|
|
65
|
+
connection = Lepus.config.create_connection(suffix: @conn_suffix)
|
|
66
|
+
@mutex.with_write_lock do
|
|
67
|
+
@in_use << connection
|
|
68
|
+
end
|
|
69
|
+
connection
|
|
70
|
+
rescue => e
|
|
71
|
+
@semaphore.release
|
|
72
|
+
raise e
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def checkin(connection)
|
|
76
|
+
return unless connection
|
|
77
|
+
|
|
78
|
+
@mutex.with_write_lock do
|
|
79
|
+
@in_use.delete(connection)
|
|
80
|
+
if connection.connected? && !@shutdown.value
|
|
81
|
+
@available << connection
|
|
82
|
+
else
|
|
83
|
+
begin
|
|
84
|
+
connection.close
|
|
85
|
+
rescue
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
@semaphore.release
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def shutdown
|
|
94
|
+
@shutdown.make_true
|
|
95
|
+
|
|
96
|
+
@mutex.with_write_lock do
|
|
97
|
+
(@available + @in_use).each do |connection|
|
|
98
|
+
connection.close
|
|
99
|
+
rescue
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
@available.clear
|
|
103
|
+
@in_use.clear
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def available?
|
|
108
|
+
!@shutdown.value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def size
|
|
112
|
+
@mutex.with_read_lock do
|
|
113
|
+
@available.length + @in_use.length
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def available_count
|
|
118
|
+
@mutex.with_read_lock do
|
|
119
|
+
@available.length
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def in_use_count
|
|
124
|
+
@mutex.with_read_lock do
|
|
125
|
+
@in_use.length
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Error raised when connection pool times out
|
|
131
|
+
class ConnectionPoolTimeoutError < StandardError; end
|
|
132
|
+
|
|
133
|
+
# Error raised when connection pool encounters an error
|
|
134
|
+
class ConnectionPoolError < StandardError; end
|
|
135
|
+
end
|
data/lib/lepus/consumer.rb
CHANGED
|
@@ -26,32 +26,22 @@ module Lepus
|
|
|
26
26
|
return @config if defined?(@config)
|
|
27
27
|
|
|
28
28
|
name = Primitive::String.new(to_s).underscore.split("/").last
|
|
29
|
-
@config =
|
|
29
|
+
@config = Consumers::Config.new(queue: name, exchange: name)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
# @return [
|
|
34
|
-
def
|
|
35
|
-
@
|
|
32
|
+
# Returns the middleware chain for this consumer.
|
|
33
|
+
# @return [Lepus::Consumers::MiddlewareChain]
|
|
34
|
+
def middleware_chain
|
|
35
|
+
@middleware_chain ||= Consumers::MiddlewareChain.new
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Registers a
|
|
38
|
+
# Registers a middleware to this consumer's chain.
|
|
39
39
|
#
|
|
40
|
-
# @param [Symbol, Class<Lepus::Middleware>]
|
|
41
|
-
# @param [Hash]
|
|
40
|
+
# @param middleware [Symbol, String, Class<Lepus::Middleware>] The middleware to register.
|
|
41
|
+
# @param opts [Hash] Options passed to the middleware constructor.
|
|
42
|
+
# @return [Lepus::Consumers::MiddlewareChain]
|
|
42
43
|
def use(middleware, opts = {})
|
|
43
|
-
|
|
44
|
-
begin
|
|
45
|
-
require_relative "middlewares/#{middleware}"
|
|
46
|
-
class_name = Primitive::String.new(middleware.to_s).classify
|
|
47
|
-
class_name = "JSON" if class_name == "Json"
|
|
48
|
-
middleware = Lepus::Middlewares.const_get(class_name)
|
|
49
|
-
rescue LoadError, NameError
|
|
50
|
-
raise ArgumentError, "Middleware #{middleware} not found"
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
middlewares << middleware.new(**opts)
|
|
44
|
+
middleware_chain.use(middleware, opts)
|
|
55
45
|
end
|
|
56
46
|
|
|
57
47
|
# Configures the consumer, setting queue, exchange and other options to be used by
|
|
@@ -64,7 +54,9 @@ module Lepus
|
|
|
64
54
|
# @option opts [Boolean, Hash] :retry_queue (false) Whether a retry queue should be provided.
|
|
65
55
|
# @option opts [Boolean, Hash] :error_queue (false) Whether an error queue should be provided.
|
|
66
56
|
def configure(opts = {})
|
|
67
|
-
|
|
57
|
+
raise ArgumentError, "Cannot configure an abstract class" if abstract_class?
|
|
58
|
+
|
|
59
|
+
@config = Consumers::Config.new(opts)
|
|
68
60
|
yield(@config) if block_given?
|
|
69
61
|
@config
|
|
70
62
|
end
|
|
@@ -95,24 +87,37 @@ module Lepus
|
|
|
95
87
|
# @param [String] payload The payload of the received message.
|
|
96
88
|
# @raise [InvalidConsumerReturnError] if you return something other than +:ack+, +:reject+ or +:requeue+ from {#perform}.
|
|
97
89
|
def process_delivery(delivery_info, metadata, payload)
|
|
98
|
-
message = Message.
|
|
99
|
-
self
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.
|
|
103
|
-
.
|
|
104
|
-
|
|
90
|
+
message = Message.coerce(delivery_info, metadata, payload)
|
|
91
|
+
message.consumer_class = self.class
|
|
92
|
+
|
|
93
|
+
combined_chain = MiddlewareChain.combine(
|
|
94
|
+
Lepus.config.consumer_middleware_chain,
|
|
95
|
+
self.class.middleware_chain
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
combined_chain.execute(message) do |msg|
|
|
99
|
+
perform(msg).tap do |result|
|
|
100
|
+
verify_result(result)
|
|
105
101
|
end
|
|
106
|
-
|
|
102
|
+
end
|
|
107
103
|
rescue Lepus::InvalidConsumerReturnError
|
|
108
104
|
raise
|
|
109
|
-
rescue Exception
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
106
|
+
on_delivery_error
|
|
107
|
+
# In testing mode, re-raise exceptions if consumer_raise_errors? is enabled
|
|
108
|
+
if defined?(Lepus::Testing) && Lepus::Testing.consumer_raise_errors?
|
|
109
|
+
raise
|
|
110
|
+
end
|
|
112
111
|
|
|
113
112
|
reject!
|
|
114
113
|
end
|
|
115
114
|
|
|
115
|
+
# Returns whether the last delivery resulted in an error.
|
|
116
|
+
# Always false in core; overridden by Lepus::Web when loaded.
|
|
117
|
+
def last_delivery_errored?
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
116
121
|
protected
|
|
117
122
|
|
|
118
123
|
def logger
|
|
@@ -154,18 +159,31 @@ module Lepus
|
|
|
154
159
|
|
|
155
160
|
private
|
|
156
161
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
# Publishes a message using the consumer's own exchange configuration.
|
|
163
|
+
# When exchange_name is different from the consumer's exchange, uses default options.
|
|
164
|
+
#
|
|
165
|
+
# @param [String, Hash] message The message to publish
|
|
166
|
+
# @param [String, nil] exchange_name Override the exchange name (optional)
|
|
167
|
+
# @param [Hash] options Additional publish options
|
|
168
|
+
# @return [void]
|
|
169
|
+
def publish_message(message, exchange_name: nil, channel: nil, **options)
|
|
170
|
+
target_exchange = exchange_name || self.class.config.exchange_name
|
|
171
|
+
return unless Lepus::Producers.exchange_enabled?(target_exchange)
|
|
172
|
+
|
|
173
|
+
opts = (target_exchange == self.class.config.exchange_name) ? self.class.config.exchange_options : {}
|
|
174
|
+
opts.merge!(options)
|
|
175
|
+
|
|
176
|
+
channel ||= instance_variable_get(:@_handler_channel) # The Lepus::Consumers::Handler sets this variable
|
|
177
|
+
if channel
|
|
178
|
+
Lepus::Publisher.new(target_exchange, **opts).channel_publish(channel, message, **opts)
|
|
179
|
+
else
|
|
180
|
+
Lepus::Publisher.new(target_exchange, **opts).publish(message, **opts)
|
|
162
181
|
end
|
|
163
182
|
end
|
|
164
183
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
end
|
|
184
|
+
# Hook called when a delivery raises an exception.
|
|
185
|
+
# No-op in core; overridden by Lepus::Web to track error state.
|
|
186
|
+
def on_delivery_error
|
|
169
187
|
end
|
|
170
188
|
|
|
171
189
|
def verify_result(result)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require "bunny"
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
# Parse the list of options for the consumer.
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_EXCHANGE_OPTIONS = {
|
|
8
|
+
name: nil,
|
|
9
|
+
type: :topic, # The type of the exchange (:direct, :fanout, :topic or :headers).
|
|
10
|
+
durable: true
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
DEFAULT_CHANNEL_OPTIONS = {
|
|
14
|
+
pool_size: 1,
|
|
15
|
+
abort_on_exception: false,
|
|
16
|
+
shutdown_timeout: 60
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
DEFAULT_QUEUE_OPTIONS = {
|
|
20
|
+
name: nil,
|
|
21
|
+
durable: true
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
DEFAULT_PREFETCH_COUNT = 1
|
|
25
|
+
|
|
26
|
+
DEFAULT_WORKER_OPTIONS = {
|
|
27
|
+
name: "default",
|
|
28
|
+
threads: 1
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
DEFAULT_RETRY_QUEUE_OPTIONS = {
|
|
32
|
+
name: nil,
|
|
33
|
+
durable: true,
|
|
34
|
+
delay: 5000,
|
|
35
|
+
arguments: {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
DEFAULT_ERROR_QUEUE_OPTIONS = DEFAULT_QUEUE_OPTIONS
|
|
39
|
+
|
|
40
|
+
attr_reader :options, :prefetch_count
|
|
41
|
+
|
|
42
|
+
def initialize(options = {})
|
|
43
|
+
opts = Lepus::Primitive::Hash.new(options).deep_symbolize_keys
|
|
44
|
+
|
|
45
|
+
@worker_opts = DEFAULT_WORKER_OPTIONS.merge(
|
|
46
|
+
declaration_config(opts.delete(:worker))
|
|
47
|
+
)
|
|
48
|
+
@exchange_opts = DEFAULT_EXCHANGE_OPTIONS.merge(
|
|
49
|
+
declaration_config(opts.delete(:exchange))
|
|
50
|
+
)
|
|
51
|
+
@queue_opts = DEFAULT_QUEUE_OPTIONS.merge(
|
|
52
|
+
declaration_config(opts.delete(:queue))
|
|
53
|
+
)
|
|
54
|
+
if (value = opts.delete(:retry_queue))
|
|
55
|
+
@retry_queue_opts = DEFAULT_RETRY_QUEUE_OPTIONS.merge(
|
|
56
|
+
declaration_config(value)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
if (value = opts.delete(:error_queue))
|
|
60
|
+
@error_queue_opts = DEFAULT_ERROR_QUEUE_OPTIONS.merge(
|
|
61
|
+
declaration_config(value)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
@channel_opts = DEFAULT_CHANNEL_OPTIONS.merge(opts.delete(:channel) || {})
|
|
65
|
+
@bind_opts = opts.delete(:bind) || {}
|
|
66
|
+
if (routing_key = opts.delete(:routing_key))
|
|
67
|
+
@bind_opts[:routing_key] ||= routing_key
|
|
68
|
+
end
|
|
69
|
+
@prefetch_count = opts.key?(:prefetch) ? opts.delete(:prefetch) : DEFAULT_PREFETCH_COUNT
|
|
70
|
+
@options = opts
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def channel_args
|
|
74
|
+
[
|
|
75
|
+
nil,
|
|
76
|
+
*@channel_opts.values_at(
|
|
77
|
+
:pool_size,
|
|
78
|
+
:abort_on_exception,
|
|
79
|
+
:shutdown_timeout
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def exchange_name
|
|
85
|
+
@exchange_opts[:name] || raise(InvalidConsumerConfigError, "Exchange name is required")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def exchange_options
|
|
89
|
+
@exchange_opts.reject { |k, v| k == :name }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def consumer_queue_args
|
|
93
|
+
opts = @queue_opts.reject { |k, v| k == :name }
|
|
94
|
+
return [queue_name, opts] unless retry_queue_args
|
|
95
|
+
|
|
96
|
+
opts[:arguments] ||= {}
|
|
97
|
+
opts[:arguments]["x-dead-letter-exchange"] = ""
|
|
98
|
+
opts[:arguments]["x-dead-letter-routing-key"] = retry_queue_name
|
|
99
|
+
|
|
100
|
+
[queue_name, opts]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def retry_queue_args
|
|
104
|
+
return unless @retry_queue_opts
|
|
105
|
+
|
|
106
|
+
delay = @retry_queue_opts[:delay]
|
|
107
|
+
args = (@retry_queue_opts[:arguments] || {}).merge(
|
|
108
|
+
"x-dead-letter-exchange" => "",
|
|
109
|
+
"x-dead-letter-routing-key" => queue_name,
|
|
110
|
+
"x-message-ttl" => delay
|
|
111
|
+
)
|
|
112
|
+
extra_keys = %i[name delay]
|
|
113
|
+
opts = @retry_queue_opts.reject { |k, v| extra_keys.include?(k) }
|
|
114
|
+
[retry_queue_name, opts.merge(arguments: args)]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def error_queue_args
|
|
118
|
+
return unless @error_queue_opts
|
|
119
|
+
|
|
120
|
+
[error_queue_name, @error_queue_opts.reject { |k, v| k == :name }]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def binds_args
|
|
124
|
+
arguments = @bind_opts.fetch(:arguments, {}).transform_keys(&:to_s)
|
|
125
|
+
opts = {}
|
|
126
|
+
opts[:arguments] = arguments unless arguments.empty?
|
|
127
|
+
if (routing_keys = @bind_opts[:routing_key]).is_a?(Array)
|
|
128
|
+
routing_keys.map { |key| opts.merge(routing_key: key) }
|
|
129
|
+
elsif (routing_key = @bind_opts[:routing_key])
|
|
130
|
+
[opts.merge(routing_key: routing_key)]
|
|
131
|
+
elsif @exchange_opts[:type] == :topic
|
|
132
|
+
[opts.merge(routing_key: "#")]
|
|
133
|
+
else
|
|
134
|
+
[opts]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def worker_name
|
|
139
|
+
@worker_opts.fetch(:name, DEFAULT_WORKER_OPTIONS[:name])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def worker_threads
|
|
143
|
+
threads = @worker_opts.fetch(:threads, DEFAULT_WORKER_OPTIONS[:threads])
|
|
144
|
+
if threads.to_i < 1
|
|
145
|
+
raise InvalidConsumerConfigError, "Worker threads must be at least 1"
|
|
146
|
+
end
|
|
147
|
+
threads
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def queue_name
|
|
151
|
+
@queue_opts[:name] || raise(InvalidConsumerConfigError, "Queue name is required")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def retry_queue_name
|
|
155
|
+
name = @retry_queue_opts[:name]
|
|
156
|
+
name ||= "#{queue_name}.retry"
|
|
157
|
+
name
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def error_queue_name
|
|
161
|
+
name = @error_queue_opts[:name]
|
|
162
|
+
name ||= "#{queue_name}.error"
|
|
163
|
+
name
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
protected
|
|
167
|
+
|
|
168
|
+
# Normalizes a declaration config (for exchanges and queues) into a configuration Hash.
|
|
169
|
+
#
|
|
170
|
+
# If the given `value` is a String, convert it to a Hash with the key `:name` and the value.
|
|
171
|
+
# If the given `value` is a Hash, leave it as is.
|
|
172
|
+
def declaration_config(value)
|
|
173
|
+
case value
|
|
174
|
+
when Hash then value
|
|
175
|
+
when String then {name: value}
|
|
176
|
+
when Symbol then {name: value.to_s}
|
|
177
|
+
when NilClass then {}
|
|
178
|
+
when TrueClass then {}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|