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
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,SecondConsumer ... ,NthConsumer", "Run Consumer"
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
- unless consumers.empty?
16
- opts[:consumers] = consumers.split(",").map(&:strip)
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
@@ -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
@@ -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 = ConsumerConfig.new(queue: name, exchange: name)
29
+ @config = Consumers::Config.new(queue: name, exchange: name)
30
30
  end
31
31
 
32
- # List of registered middlewares. Register new middlewares with {.use}.
33
- # @return [Array<Lepus::Middleware>]
34
- def middlewares
35
- @middlewares ||= []
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 new middleware by instantiating +middleware+ and passing it +opts+.
38
+ # Registers a middleware to this consumer's chain.
39
39
  #
40
- # @param [Symbol, Class<Lepus::Middleware>] middleware The middleware class to instantiate and register.
41
- # @param [Hash] opts The options for instantiating the middleware.
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
- if middleware.is_a?(Symbol) || middleware.is_a?(String)
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
- @config = ConsumerConfig.new(opts)
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.new(delivery_info, metadata, payload)
99
- self
100
- .class
101
- .middlewares
102
- .reverse
103
- .reduce(work_proc) do |next_middleware, middleware|
104
- nest_middleware(middleware, next_middleware)
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
- .call(message)
102
+ end
107
103
  rescue Lepus::InvalidConsumerReturnError
108
104
  raise
109
- rescue Exception => ex # rubocop:disable Lint/RescueException
110
- # @TODO: add error handling
111
- logger.error(ex)
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
- def work_proc
158
- ->(message) do
159
- perform(message).tap do |result|
160
- verify_result(result)
161
- end
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
- def nest_middleware(middleware, next_middleware)
166
- ->(message) do
167
- middleware.call(message, next_middleware)
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