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,56 @@
|
|
|
1
|
+
require "bunny"
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
# Wraps the user-defined consumer to provide the expected interface to Bunny.
|
|
6
|
+
class Handler < Bunny::Consumer
|
|
7
|
+
# @param [Lepus::Consumer] consumer_class The user-defined consumer implementation derived from {Lepus::Consumer}.
|
|
8
|
+
# @param [Bunny::Channel] channel The channel used for the consumer.
|
|
9
|
+
# @param [Bunny::Queue] queue The queue the consumer is subscribed to.
|
|
10
|
+
# @param [String] consumer_tag A string identifying the consumer instance.
|
|
11
|
+
# @param [Hash] arguments Arguments that are passed on to +Bunny::Consumer.new+.
|
|
12
|
+
def initialize(consumer_class, channel, queue, consumer_tag, arguments = {})
|
|
13
|
+
@consumer_class = consumer_class
|
|
14
|
+
super(channel, queue, consumer_tag, _no_ack = false, _exclusive = false, arguments)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Called when a message is received from the subscribed queue.
|
|
18
|
+
#
|
|
19
|
+
# @param [Bunny::DeliveryInfo] delivery_info The delivery info of the received message.
|
|
20
|
+
# @param [Bunny::MessageProperties] metadata The metadata of the received message.
|
|
21
|
+
# @param [String] payload The payload of the received message.
|
|
22
|
+
def process_delivery(delivery_info, metadata, payload)
|
|
23
|
+
consumer
|
|
24
|
+
.process_delivery(delivery_info, metadata, payload)
|
|
25
|
+
.tap do |result|
|
|
26
|
+
process_result(result, delivery_info.delivery_tag)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def consumer
|
|
33
|
+
@consumer ||= begin
|
|
34
|
+
instance = @consumer_class.new
|
|
35
|
+
instance.instance_variable_set(:@_handler_channel, channel)
|
|
36
|
+
instance
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def process_result(result, delivery_tag)
|
|
41
|
+
case result
|
|
42
|
+
when :ack
|
|
43
|
+
channel.ack(delivery_tag, false)
|
|
44
|
+
when :reject
|
|
45
|
+
channel.reject(delivery_tag)
|
|
46
|
+
when :requeue
|
|
47
|
+
channel.reject(delivery_tag, true)
|
|
48
|
+
when :nack
|
|
49
|
+
channel.nack(delivery_tag, false, true)
|
|
50
|
+
else
|
|
51
|
+
raise Lepus::InvalidConsumerReturnError, result
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
# Manages middleware registration and execution for consumers.
|
|
6
|
+
# Middlewares can modify the message (payload, headers, routing_key, etc.)
|
|
7
|
+
# before it is processed by the consumer.
|
|
8
|
+
class MiddlewareChain < Lepus::MiddlewareChain
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def load_middleware(name, opts)
|
|
12
|
+
require_relative "middlewares/#{name}"
|
|
13
|
+
class_name = Primitive::String.new(name.to_s).classify
|
|
14
|
+
class_name = "JSON" if class_name == "Json"
|
|
15
|
+
klass = Lepus::Consumers::Middlewares.const_get(class_name)
|
|
16
|
+
klass.new(**opts)
|
|
17
|
+
rescue LoadError, NameError => e
|
|
18
|
+
raise ArgumentError, "Consumer middleware '#{name}' not found: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
module Middlewares
|
|
6
|
+
# A middleware that logs exceptions raised by downstream middleware/consumers.
|
|
7
|
+
# Default logger is Lepus.logger.
|
|
8
|
+
class ExceptionLogger < Lepus::Middleware
|
|
9
|
+
# @param [Hash] opts The options for the middleware.
|
|
10
|
+
# @option opts [Logger] :logger The logger to use. Defaults to Lepus.logger.
|
|
11
|
+
def initialize(logger: Lepus.logger, **)
|
|
12
|
+
super
|
|
13
|
+
|
|
14
|
+
@logger = logger
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(message, app)
|
|
18
|
+
app.call(message)
|
|
19
|
+
rescue => err
|
|
20
|
+
# Log error message; let outer layers decide how to handle the exception
|
|
21
|
+
@logger.error(err.message)
|
|
22
|
+
raise err
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
module Middlewares
|
|
6
|
+
# A middleware that automatically wraps {Lepus::Consumer#perform]} in an Honeybadger transaction.
|
|
7
|
+
class Honeybadger < Lepus::Middleware
|
|
8
|
+
# @param [Hash] opts The options for the middleware.
|
|
9
|
+
# @option opts [String] :class_name The name of the class you want to monitor.
|
|
10
|
+
def initialize(class_name: nil, **)
|
|
11
|
+
super
|
|
12
|
+
|
|
13
|
+
@class_name = class_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(message, app)
|
|
17
|
+
app.call(message)
|
|
18
|
+
rescue => err
|
|
19
|
+
context = build_context(message)
|
|
20
|
+
::Honeybadger.notify(err, context: context)
|
|
21
|
+
raise err
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_context(message)
|
|
27
|
+
class_name = @class_name || message.consumer_class&.name
|
|
28
|
+
class_name ? {class_name: class_name} : {}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_json"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Consumers
|
|
7
|
+
module Middlewares
|
|
8
|
+
# A middleware that automatically parses your JSON payload.
|
|
9
|
+
class JSON < Lepus::Middleware
|
|
10
|
+
# @param [Hash] opts The options for the middleware.
|
|
11
|
+
# @option opts [Proc] :on_error (Proc.new { :reject }) A Proc to be called when an error occurs during processing.
|
|
12
|
+
# @option opts [Boolean] :symbolize_keys (false) Whether to symbolize the keys of your payload.
|
|
13
|
+
def initialize(**opts)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
@on_error = opts.fetch(:on_error, proc { :reject })
|
|
17
|
+
@symbolize_keys = opts.fetch(:symbolize_keys, false)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(message, app)
|
|
21
|
+
begin
|
|
22
|
+
parsed_payload =
|
|
23
|
+
MultiJson.load(message.payload, symbolize_keys: symbolize_keys)
|
|
24
|
+
rescue => e
|
|
25
|
+
return on_error.call(e)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
app.call(message.mutate(payload: parsed_payload))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :symbolize_keys, :on_error
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
module Middlewares
|
|
6
|
+
# A middleware that automatically puts messages on an error queue when the specified number of retries are exceeded.
|
|
7
|
+
class MaxRetry < Lepus::Middleware
|
|
8
|
+
include Lepus::AppExecutor
|
|
9
|
+
|
|
10
|
+
# @param [Hash] opts The options for the middleware.
|
|
11
|
+
# @option opts [Integer] :retries The number of retries before the message is sent to the error queue.
|
|
12
|
+
# @option opts [String] :error_queue The name of the queue where messages should be sent to when the max retries are reached.
|
|
13
|
+
# If not provided, will fallback to the consumer's configured error queue name.
|
|
14
|
+
def initialize(retries:, error_queue: nil)
|
|
15
|
+
super
|
|
16
|
+
|
|
17
|
+
@retries = retries
|
|
18
|
+
@error_queue = error_queue
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(message, app)
|
|
22
|
+
return handle_exceeded(message) if retries_exceeded?(message.metadata)
|
|
23
|
+
|
|
24
|
+
app.call(message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :retries
|
|
30
|
+
|
|
31
|
+
def handle_exceeded(message)
|
|
32
|
+
payload = message.payload
|
|
33
|
+
payload = MultiJson.dump(payload) if payload.is_a?(Hash)
|
|
34
|
+
::Bunny::Exchange.default(message.channel).publish(
|
|
35
|
+
payload,
|
|
36
|
+
routing_key: error_queue_name(message)
|
|
37
|
+
)
|
|
38
|
+
:ack
|
|
39
|
+
rescue Lepus::InvalidConsumerConfigError => err
|
|
40
|
+
raise err
|
|
41
|
+
rescue => err
|
|
42
|
+
handle_thread_error(err)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def error_queue_name(message)
|
|
46
|
+
return @error_queue if @error_queue
|
|
47
|
+
|
|
48
|
+
default_error_queue_name(message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def default_error_queue_name(message)
|
|
52
|
+
unless message.consumer_class&.config
|
|
53
|
+
raise Lepus::InvalidConsumerConfigError, "Error queue name is required and consumer class is not available"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
config = message.consumer_class.config
|
|
57
|
+
unless config.error_queue_name
|
|
58
|
+
raise Lepus::InvalidConsumerConfigError, "Error queue name is required. Configure error_queue in consumer config or provide error_queue option to middleware"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
config.error_queue_name
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def retries_exceeded?(metadata)
|
|
65
|
+
return false if metadata.headers.nil?
|
|
66
|
+
|
|
67
|
+
rejected_deaths =
|
|
68
|
+
metadata
|
|
69
|
+
.headers
|
|
70
|
+
.fetch("x-death", [])
|
|
71
|
+
.select { |death| death["reason"] == "rejected" }
|
|
72
|
+
|
|
73
|
+
return false unless rejected_deaths.any?
|
|
74
|
+
|
|
75
|
+
retry_count = rejected_deaths.map { |death| death["count"] }.compact.max
|
|
76
|
+
return false unless retry_count
|
|
77
|
+
|
|
78
|
+
retry_count > @retries
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
module Middlewares
|
|
6
|
+
# A middleware that releases deduplication locks after message processing.
|
|
7
|
+
#
|
|
8
|
+
# Works in tandem with Lepus::Producers::Middlewares::Unique. The producer
|
|
9
|
+
# acquires a lock and embeds the lock info in message headers. This consumer
|
|
10
|
+
# middleware reads those headers and releases the lock based on the configured
|
|
11
|
+
# +release_on+ conditions.
|
|
12
|
+
#
|
|
13
|
+
# @example Release on ack (default)
|
|
14
|
+
# use :unique
|
|
15
|
+
#
|
|
16
|
+
# @example Release on ack or reject
|
|
17
|
+
# use :unique, release_on: [:ack, :reject]
|
|
18
|
+
#
|
|
19
|
+
# @example Release on error (e.g., dead-letter scenarios)
|
|
20
|
+
# use :unique, release_on: [:ack, :error]
|
|
21
|
+
class Unique < Lepus::Middleware
|
|
22
|
+
HEADER_LOCK_KEY = "x-dedupe-lock-key"
|
|
23
|
+
HEADER_LOCK_ID = "x-dedupe-lock-id"
|
|
24
|
+
HEADER_LOCK_TTL = "x-dedupe-lock-ttl"
|
|
25
|
+
|
|
26
|
+
# @param release_on [Array<Symbol>] Conditions that trigger lock release.
|
|
27
|
+
# Valid values: +:ack+, +:reject+, +:requeue+, +:nack+, +:error+.
|
|
28
|
+
# Defaults to +[:ack]+.
|
|
29
|
+
def initialize(release_on: [:ack])
|
|
30
|
+
super()
|
|
31
|
+
@release_on = Array(release_on)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(message, app)
|
|
35
|
+
result = app.call(message)
|
|
36
|
+
|
|
37
|
+
if @release_on.include?(result)
|
|
38
|
+
release_lock(message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
result
|
|
42
|
+
rescue
|
|
43
|
+
release_lock(message) if @release_on.include?(:error)
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def release_lock(message)
|
|
50
|
+
headers = message.metadata&.headers
|
|
51
|
+
return unless headers
|
|
52
|
+
|
|
53
|
+
lock_key = headers[HEADER_LOCK_KEY]
|
|
54
|
+
lock_id = headers[HEADER_LOCK_ID]
|
|
55
|
+
return unless lock_key && lock_id
|
|
56
|
+
|
|
57
|
+
lock_opts = {}
|
|
58
|
+
lock_opts[:ttl] = headers[HEADER_LOCK_TTL] if headers[HEADER_LOCK_TTL]
|
|
59
|
+
lock = DeDupe::Lock.new(lock_key: lock_key, lock_id: lock_id, **lock_opts)
|
|
60
|
+
lock.release
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Consumers
|
|
7
|
+
# Thread-safe per-consumer-class statistics tracker.
|
|
8
|
+
# Uses atomic counters to track processed/rejected/errored message counts.
|
|
9
|
+
class Stats
|
|
10
|
+
attr_reader :consumer_class
|
|
11
|
+
|
|
12
|
+
def initialize(consumer_class)
|
|
13
|
+
@consumer_class = consumer_class
|
|
14
|
+
@processed = Concurrent::AtomicFixnum.new(0)
|
|
15
|
+
@rejected = Concurrent::AtomicFixnum.new(0)
|
|
16
|
+
@errored = Concurrent::AtomicFixnum.new(0)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record_processed
|
|
20
|
+
@processed.increment
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record_rejected
|
|
24
|
+
@rejected.increment
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_errored
|
|
28
|
+
@errored.increment
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def processed
|
|
32
|
+
@processed.value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rejected
|
|
36
|
+
@rejected.value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def errored
|
|
40
|
+
@errored.value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
config = consumer_class.config
|
|
45
|
+
{
|
|
46
|
+
class_name: consumer_class.name,
|
|
47
|
+
exchange: config.exchange_name,
|
|
48
|
+
queue: config.queue_name,
|
|
49
|
+
route: extract_route(config),
|
|
50
|
+
threads: config.worker_threads,
|
|
51
|
+
processed: @processed.value,
|
|
52
|
+
rejected: @rejected.value,
|
|
53
|
+
errored: @errored.value
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def extract_route(config)
|
|
60
|
+
binds = config.binds_args
|
|
61
|
+
return nil if binds.empty?
|
|
62
|
+
|
|
63
|
+
keys = binds.filter_map { |b| b[:routing_key] }
|
|
64
|
+
return nil if keys.empty?
|
|
65
|
+
|
|
66
|
+
(keys.length == 1) ? keys.first : keys
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Consumers
|
|
7
|
+
# Per-worker registry of consumer stats.
|
|
8
|
+
# Uses Concurrent::Map for thread-safe lazy initialization.
|
|
9
|
+
class StatsRegistry
|
|
10
|
+
def initialize
|
|
11
|
+
@stats = Concurrent::Map.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def for(consumer_class)
|
|
15
|
+
@stats.compute_if_absent(consumer_class.name) do
|
|
16
|
+
Stats.new(consumer_class)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all
|
|
21
|
+
@stats.values.map(&:to_h)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def connection_count
|
|
25
|
+
@stats.size
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Consumers
|
|
7
|
+
class Worker < Processes::Base
|
|
8
|
+
include Processes::Runnable
|
|
9
|
+
|
|
10
|
+
extend Forwardable
|
|
11
|
+
|
|
12
|
+
def_delegators :definer, :name, :consumers
|
|
13
|
+
|
|
14
|
+
attr_reader :definer
|
|
15
|
+
|
|
16
|
+
def initialize(definer, **options)
|
|
17
|
+
@definer = definer
|
|
18
|
+
|
|
19
|
+
super(**options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def metadata
|
|
23
|
+
super.merge(name: name, consumers: consumers.map(&:to_s))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def before_fork
|
|
27
|
+
consumers.each do |consumer_class|
|
|
28
|
+
next unless consumer_class.respond_to?(:before_fork, true)
|
|
29
|
+
|
|
30
|
+
warn "[DEPRECATION] #{consumer_class}.before_fork is deprecated and will be removed in Lepus 0.0.1. Use the process-level before_fork hook instead."
|
|
31
|
+
|
|
32
|
+
consumer_class.send(:before_fork)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
definer.run_process_callbacks(:before_fork)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def after_fork
|
|
39
|
+
consumers.each do |consumer_class|
|
|
40
|
+
next unless consumer_class.respond_to?(:after_fork, true)
|
|
41
|
+
|
|
42
|
+
warn "[DEPRECATION] #{consumer_class}.after_fork is deprecated and will be removed in Lepus 0.0.1. Use the process-level after_fork hook instead."
|
|
43
|
+
|
|
44
|
+
consumer_class.send(:after_fork)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
definer.run_process_callbacks(:after_fork)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
SLEEP_INTERVAL = 5
|
|
53
|
+
|
|
54
|
+
def run
|
|
55
|
+
wrap_in_app_executor do
|
|
56
|
+
setup_consumers!
|
|
57
|
+
rescue Bunny::TCPConnectionFailed, Bunny::PossibleAuthenticationFailureError, Bunny::PreconditionFailed => e
|
|
58
|
+
raise Lepus::ShutdownError.new(e.message)
|
|
59
|
+
rescue Lepus::InvalidConsumerConfigError => e
|
|
60
|
+
raise Lepus::ShutdownError.new(e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
loop do
|
|
64
|
+
break if shutting_down?
|
|
65
|
+
|
|
66
|
+
wrap_in_app_executor do
|
|
67
|
+
interruptible_sleep(SLEEP_INTERVAL)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
ensure
|
|
71
|
+
Lepus.instrument(:shutdown_process, process: self) do
|
|
72
|
+
run_process_callbacks(:shutdown) { shutdown }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def shutdown
|
|
77
|
+
@subscriptions.to_a.each(&:cancel)
|
|
78
|
+
@connection_pool&.shutdown
|
|
79
|
+
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_procline
|
|
84
|
+
procline "#{kind.downcase}-#{name}: #{consumers.size} consumers"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def setup_consumers!
|
|
88
|
+
@subscriptions = consumers.flat_map do |consumer_class|
|
|
89
|
+
consumer_config = consumer_class.config
|
|
90
|
+
|
|
91
|
+
Array.new(consumer_config.worker_threads) do |n|
|
|
92
|
+
connection_pool.with_connection do |bunny|
|
|
93
|
+
channel = bunny.create_channel(*consumer_config.channel_args)
|
|
94
|
+
channel.basic_qos(consumer_config.prefetch_count) if consumer_config.prefetch_count
|
|
95
|
+
channel.on_uncaught_exception do |error|
|
|
96
|
+
handle_thread_error(error)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
exchange = channel.exchange(consumer_config.exchange_name, **consumer_config.exchange_options)
|
|
100
|
+
|
|
101
|
+
if (args = consumer_config.retry_queue_args)
|
|
102
|
+
_retry_queue = channel.queue(*args)
|
|
103
|
+
end
|
|
104
|
+
if (args = consumer_config.error_queue_args)
|
|
105
|
+
_error_queue = channel.queue(*args)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
main_queue = channel.queue(*consumer_config.consumer_queue_args)
|
|
109
|
+
consumer_config.binds_args.each do |opts|
|
|
110
|
+
main_queue.bind(exchange, **opts)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
consumer_handler = build_handler(consumer_class, channel, main_queue, "#{consumer_class.name}-#{n + 1}")
|
|
114
|
+
|
|
115
|
+
consumer_handler.on_delivery do |delivery_info, metadata, payload|
|
|
116
|
+
consumer_handler.process_delivery(delivery_info, metadata, payload)
|
|
117
|
+
end
|
|
118
|
+
main_queue.subscribe_with(consumer_handler)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Factory method for creating consumer handlers.
|
|
125
|
+
# Overridden by Lepus::Web to attach stats tracking.
|
|
126
|
+
def build_handler(consumer_class, channel, queue, tag)
|
|
127
|
+
Lepus::Consumers::Handler.new(consumer_class, channel, queue, tag)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def connection_pool
|
|
131
|
+
return @connection_pool if defined?(@connection_pool)
|
|
132
|
+
|
|
133
|
+
@connection_pool = Lepus::ConnectionPool.new(
|
|
134
|
+
size: definer.pool_size,
|
|
135
|
+
timeout: definer.pool_timeout,
|
|
136
|
+
suffix: definer.name
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Consumers
|
|
5
|
+
# This is a configuration object for defining process-level settings
|
|
6
|
+
# It holds settings such as connection pool size, timeouts, and alive thresholds and
|
|
7
|
+
# more importantly, the list of consumers that should be run in this process.
|
|
8
|
+
#
|
|
9
|
+
# Note that this class only holds configuration data related the process and does not handle
|
|
10
|
+
# the actual process management or consumer execution. Consumer has its own configuration for
|
|
11
|
+
# AMPQ settings, queue names, etc.
|
|
12
|
+
class WorkerFactory
|
|
13
|
+
DEFAULT_NAME = "default"
|
|
14
|
+
DEFAULT_POOL_SIZE = 1
|
|
15
|
+
DEFAULT_POOL_TIMEOUT = 5.0
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def [](name)
|
|
19
|
+
@instances ||= Concurrent::Map.new
|
|
20
|
+
@instances[name.to_s] ||= new(name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def default
|
|
24
|
+
self[DEFAULT_NAME]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exists?(name)
|
|
28
|
+
return false unless @instances
|
|
29
|
+
|
|
30
|
+
@instances.key?(name.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Create an immutable copy of the process configuration with the specified consumers.
|
|
34
|
+
# @param name [String, Symbol] the name of the process configuration to use.
|
|
35
|
+
# @param consumers [Array<Lepus::Consumer>] the list of consumer classes to be run in this process.
|
|
36
|
+
# @return [Lepus::Consumers::WorkerFactory] the immutable process configuration.
|
|
37
|
+
def immutate_with(name, consumers: [])
|
|
38
|
+
definer = self[name].dup
|
|
39
|
+
definer.freeze_with(consumers)
|
|
40
|
+
definer
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# This method is primarily for testing purposes to reset the instances map.
|
|
46
|
+
def clear_all
|
|
47
|
+
@instances = Concurrent::Map.new
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String] the unique name for this process configuration. Default is "default".
|
|
52
|
+
attr_reader :name
|
|
53
|
+
|
|
54
|
+
# @return [Array<Lepus::Consumer>] the list of consumer classes to be run in this process.
|
|
55
|
+
attr_reader :consumers
|
|
56
|
+
|
|
57
|
+
# @return [Integer] the size of the connection pool for this process. Default is 1.
|
|
58
|
+
attr_accessor :pool_size
|
|
59
|
+
|
|
60
|
+
# @return [Integer] the timeout in seconds to wait for a connection from the pool. Default is 5 seconds.
|
|
61
|
+
attr_accessor :pool_timeout
|
|
62
|
+
|
|
63
|
+
# You probably want to use .[] or .default to get an instance instead of calling new directly.
|
|
64
|
+
def initialize(name)
|
|
65
|
+
@name = name.to_s
|
|
66
|
+
@pool_size = DEFAULT_POOL_SIZE
|
|
67
|
+
@pool_timeout = DEFAULT_POOL_TIMEOUT
|
|
68
|
+
@consumers = []
|
|
69
|
+
@callbacks = {before_fork: [], after_fork: []}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Assign multiple attributes at once from a hash of options.
|
|
73
|
+
# @raise [ArgumentError] if an unknown attribute is provided.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def assign(options = {})
|
|
76
|
+
options.each do |key, value|
|
|
77
|
+
raise ArgumentError, "Unknown attribute #{key}" unless respond_to?(:"#{key}=")
|
|
78
|
+
|
|
79
|
+
public_send(:"#{key}=", value)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Freeze this configuration instance and set the consumers that will run in this process.
|
|
84
|
+
# @param consumers [Array<Lepus::Consumer>] the list of consumer classes to be run in this process.
|
|
85
|
+
# @return [void]
|
|
86
|
+
def freeze_with(consumers)
|
|
87
|
+
@consumers = Array(consumers).map do |consumer|
|
|
88
|
+
unless consumer <= Lepus::Consumer
|
|
89
|
+
raise ArgumentError, "#{consumer} is not a subclass of Lepus::Consumer"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
consumer
|
|
93
|
+
end.uniq.freeze
|
|
94
|
+
@callbacks = @callbacks.transform_values(&:freeze)
|
|
95
|
+
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Instantiate a new Lepus::Consumers::Worker based on this configuration.
|
|
100
|
+
# @return [Lepus::Consumers::Worker] a new instance of Lepus::Consumers::Worker configured with this definition.
|
|
101
|
+
def instantiate_process
|
|
102
|
+
Lepus::Consumers::Worker.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def before_fork(&block)
|
|
106
|
+
callbacks[:before_fork] << block if block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def after_fork(&block)
|
|
110
|
+
callbacks[:after_fork] << block if block
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def run_process_callbacks(type)
|
|
114
|
+
return unless callbacks[type]
|
|
115
|
+
|
|
116
|
+
callbacks[type].each { |callback| callback.call }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
attr_reader :callbacks
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|