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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lepus
6
+ class ProcessRegistry
7
+ # Builds heartbeat messages for RabbitMQ publishing.
8
+ class MessageBuilder
9
+ VERSION = "1.0"
10
+
11
+ def initialize(process, metrics: {})
12
+ @process = process
13
+ @metrics = metrics
14
+ end
15
+
16
+ def build_heartbeat
17
+ {
18
+ type: "heartbeat",
19
+ version: VERSION,
20
+ process: process_data,
21
+ metrics: metrics_data
22
+ }
23
+ end
24
+
25
+ def build_deregister
26
+ {
27
+ type: "deregister",
28
+ version: VERSION,
29
+ process_id: @process.id,
30
+ timestamp: Time.now.iso8601(6)
31
+ }
32
+ end
33
+
34
+ def to_json
35
+ JSON.generate(build_heartbeat)
36
+ end
37
+
38
+ private
39
+
40
+ def process_data
41
+ {
42
+ id: @process.id,
43
+ name: @process.name,
44
+ pid: @process.pid,
45
+ hostname: @process.hostname,
46
+ kind: @process.kind,
47
+ supervisor_id: @process.supervisor_id,
48
+ application: Lepus.config.application_name,
49
+ last_heartbeat_at: format_time(@process.last_heartbeat_at)
50
+ }
51
+ end
52
+
53
+ def metrics_data
54
+ {
55
+ rss_memory: @metrics[:rss_memory] || safe_rss_memory,
56
+ connections: @metrics[:connections] || 0,
57
+ consumers: @metrics[:consumers] || []
58
+ }
59
+ end
60
+
61
+ def safe_rss_memory
62
+ @process.rss_memory * 1024 # Convert kB to bytes (MEMORY_GRABBER returns kB)
63
+ rescue
64
+ 0
65
+ end
66
+
67
+ def format_time(time)
68
+ time&.iso8601(6)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Lepus
7
+ class ProcessRegistry
8
+ # RabbitMQ-based backend for process registry.
9
+ # Publishes heartbeats to a fanout exchange for web dashboard aggregation.
10
+ # Also writes locally via FileBackend for local queries when aggregator is unavailable.
11
+ class RabbitmqBackend
12
+ include Backend
13
+
14
+ HEARTBEAT_EXCHANGE = "lepus.heartbeat"
15
+
16
+ attr_reader :fallback
17
+
18
+ def initialize(fallback: nil)
19
+ @fallback = fallback || FileBackend.new
20
+ @connection = nil
21
+ @channel = nil
22
+ @exchange = nil
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def start
27
+ @fallback.start
28
+ setup_channel_and_exchange
29
+ end
30
+
31
+ def stop
32
+ @fallback.stop
33
+ close_channel
34
+ end
35
+
36
+ def add(process, metrics: {})
37
+ @fallback.add(process)
38
+ publish_heartbeat(process, metrics: metrics)
39
+ end
40
+
41
+ def delete(process)
42
+ @fallback.delete(process)
43
+ publish_deregister(process)
44
+ end
45
+
46
+ def find(id)
47
+ @fallback.find(id)
48
+ end
49
+
50
+ def exists?(id)
51
+ @fallback.exists?(id)
52
+ end
53
+
54
+ def all
55
+ @fallback.all
56
+ end
57
+
58
+ def count
59
+ @fallback.count
60
+ end
61
+
62
+ def clear
63
+ @fallback.clear
64
+ end
65
+
66
+ def path
67
+ @fallback.path
68
+ end
69
+
70
+ private
71
+
72
+ def setup_channel_and_exchange
73
+ return unless rabbitmq_available?
74
+
75
+ @mutex.synchronize do
76
+ return if @channel&.open?
77
+
78
+ @connection = Lepus.config.create_connection(suffix: "(registry)")
79
+ @channel = @connection.create_channel
80
+ @exchange = @channel.fanout(
81
+ HEARTBEAT_EXCHANGE,
82
+ durable: false,
83
+ auto_delete: false
84
+ )
85
+ end
86
+ rescue => e
87
+ Lepus.logger.warn("[ProcessRegistry] Failed to setup RabbitMQ channel: #{e.message}")
88
+ @connection = nil
89
+ @channel = nil
90
+ @exchange = nil
91
+ end
92
+
93
+ # Tear down the dedicated registry connection on supervisor shutdown.
94
+ # Bunny's graceful close waits up to 15s per channel for a broker
95
+ # `close-ok` continuation; during forked supervisor shutdown the broker
96
+ # sometimes never replies and SIGTERM handling blows past its 10s budget,
97
+ # timing out the integration specs. We bound the graceful attempt at 2s
98
+ # and fall back to closing the socket directly so the process can exit.
99
+ CLOSE_TIMEOUT = 2
100
+
101
+ def close_channel
102
+ @mutex.synchronize do
103
+ force_close_connection if @connection
104
+ end
105
+ ensure
106
+ @connection = nil
107
+ @channel = nil
108
+ @exchange = nil
109
+ end
110
+
111
+ def force_close_connection
112
+ Timeout.timeout(CLOSE_TIMEOUT) { @connection.close(false) } if @connection.open?
113
+ rescue => e
114
+ Lepus.logger.warn("[ProcessRegistry] Failed to close RabbitMQ connection: #{e.message}")
115
+ begin
116
+ @connection.instance_variable_get(:@transport)&.close
117
+ rescue
118
+ nil
119
+ end
120
+ end
121
+
122
+ def publish_heartbeat(process, metrics: {})
123
+ return unless @exchange
124
+
125
+ message = MessageBuilder.new(process, metrics: metrics)
126
+ @exchange.publish(
127
+ message.to_json,
128
+ content_type: "application/json"
129
+ )
130
+ rescue => e
131
+ Lepus.logger.warn("[ProcessRegistry] Failed to publish heartbeat: #{e.message}")
132
+ end
133
+
134
+ def publish_deregister(process)
135
+ return unless @exchange
136
+
137
+ message = MessageBuilder.new(process).build_deregister
138
+ @exchange.publish(
139
+ JSON.generate(message),
140
+ content_type: "application/json"
141
+ )
142
+ rescue => e
143
+ Lepus.logger.warn("[ProcessRegistry] Failed to publish deregister: #{e.message}")
144
+ end
145
+
146
+ def rabbitmq_available?
147
+ Lepus.config.rabbitmq_url.present?
148
+ rescue
149
+ true
150
+ end
151
+ end
152
+ end
153
+ end
@@ -1,37 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "singleton"
4
-
5
3
  module Lepus
4
+ # Process registry that delegates to a configurable backend.
5
+ # Default backend is FileBackend for local file-based storage.
6
+ # Use RabbitmqBackend to share process data across apps via web dashboard.
6
7
  class ProcessRegistry
7
- include Singleton
8
+ class << self
9
+ def backend
10
+ @backend ||= Lepus.config.build_process_registry_backend
11
+ end
8
12
 
9
- def initialize
10
- @processes = ::Concurrent::Hash.new
11
- end
13
+ attr_writer :backend
12
14
 
13
- def add(process)
14
- @processes[process.id] = process
15
- end
15
+ def reset_backend!
16
+ @backend = nil
17
+ end
16
18
 
17
- def delete(process)
18
- @processes.delete(process.id)
19
- end
19
+ def start
20
+ backend.start
21
+ end
20
22
 
21
- def find(id)
22
- @processes[id] || raise(Lepus::Process::NotFoundError.new(id))
23
- end
23
+ def stop
24
+ backend.stop
25
+ end
24
26
 
25
- def exists?(id)
26
- @processes.key?(id)
27
- end
27
+ def reset!
28
+ stop
29
+ start
30
+ end
28
31
 
29
- def all
30
- @processes.values
31
- end
32
+ def add(process)
33
+ backend.add(process)
34
+ end
35
+
36
+ def update(process, metrics: {})
37
+ backend.update(process, metrics: metrics)
38
+ end
39
+
40
+ def delete(process)
41
+ backend.delete(process)
42
+ end
43
+
44
+ def find(id)
45
+ backend.find(id)
46
+ end
47
+
48
+ def exists?(id)
49
+ backend.exists?(id)
50
+ end
51
+
52
+ def all
53
+ backend.all
54
+ end
55
+
56
+ def count
57
+ backend.count
58
+ end
59
+
60
+ def clear
61
+ backend.clear
62
+ end
32
63
 
33
- def clear
34
- @processes.clear
64
+ # For backward compatibility with tests that check @path
65
+ def path
66
+ backend.respond_to?(:path) ? backend.path : nil
67
+ end
35
68
  end
36
69
  end
37
70
  end
@@ -12,7 +12,6 @@ module Lepus
12
12
  attr_reader :name
13
13
 
14
14
  def initialize(*)
15
- @name = generate_name
16
15
  @stopped = false
17
16
  end
18
17
 
@@ -38,10 +37,6 @@ module Lepus
38
37
 
39
38
  private
40
39
 
41
- def generate_name
42
- [kind.downcase, SecureRandom.hex(10)].join("-")
43
- end
44
-
45
40
  def stopped?
46
41
  @stopped
47
42
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lepus::Processes
4
+ # Provides callback functionality for process lifecycle events.
4
5
  module Callbacks
5
6
  def self.included(base)
6
7
  base.extend(ClassMethods)
@@ -52,6 +53,8 @@ module Lepus::Processes
52
53
  @after_shutdown_callbacks.concat methods
53
54
  end
54
55
 
56
+ private
57
+
55
58
  def before_boot_callbacks
56
59
  @before_boot_callbacks || []
57
60
  end
@@ -2,14 +2,6 @@
2
2
 
3
3
  module Lepus::Processes
4
4
  module Interruptible
5
- def wake_up
6
- interrupt
7
- end
8
-
9
- private
10
-
11
- SELF_PIPE_BLOCK_SIZE = 11
12
-
13
5
  def interrupt
14
6
  self_pipe[:writer].write_nonblock(".")
15
7
  rescue Errno::EAGAIN, Errno::EINTR
@@ -18,6 +10,10 @@ module Lepus::Processes
18
10
  retry
19
11
  end
20
12
 
13
+ private
14
+
15
+ SELF_PIPE_BLOCK_SIZE = 11
16
+
21
17
  def interruptible_sleep(time)
22
18
  if time > 0 && self_pipe[:reader].wait_readable(time)
23
19
  loop { self_pipe[:reader].read_nonblock(SELF_PIPE_BLOCK_SIZE) }
@@ -5,7 +5,7 @@ module Lepus::Processes
5
5
  # Sets the procline ($0)
6
6
  # [lepus-supervisor: <string>]
7
7
  def procline(string)
8
- $0 = "[lepus-#{kind.downcase}: #{string}]"
8
+ $0 = "[lepus-#{string}]"
9
9
  end
10
10
  end
11
11
  end
@@ -60,7 +60,7 @@ module Lepus::Processes
60
60
  process.heartbeat
61
61
  rescue Process::NotFoundError
62
62
  self.process = nil
63
- wake_up
63
+ interrupt
64
64
  end
65
65
  end
66
66
  end
@@ -27,7 +27,7 @@ module Lepus::Processes
27
27
  def stop
28
28
  super
29
29
 
30
- wake_up
30
+ interrupt
31
31
  @thread&.join
32
32
  end
33
33
 
@@ -2,5 +2,20 @@
2
2
 
3
3
  module Lepus
4
4
  module Processes
5
+ MEMORY_GRABBER = case RUBY_PLATFORM
6
+ when /linux/
7
+ ->(pid) {
8
+ IO.readlines("/proc/#{$$}/status").each do |line|
9
+ next unless line.start_with?("VmRSS:")
10
+ break line.split[1].to_i
11
+ end
12
+ }
13
+ when /darwin|bsd/
14
+ ->(pid) {
15
+ `ps -o pid,rss -p #{pid}`.lines.last.split.last.to_i
16
+ }
17
+ else
18
+ ->(pid) { 0 }
19
+ end
5
20
  end
6
21
  end
@@ -1,42 +1,153 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lepus
4
+ # The abstract base class for producers publishing messages to exchanges.
5
+ # @abstract Subclass and override {#configure} to implement.
4
6
  class Producer
5
- DEFAULT_EXCHANGE_OPTIONS = {
6
- type: :topic,
7
- durable: true,
8
- auto_delete: false
9
- }.freeze
10
-
11
- DEFAULT_PUBLISH_OPTIONS = {
12
- expiration: 7 * (60 * 60 * 24)
13
- }.freeze
14
-
15
- def initialize(exchange_name, **options)
16
- @exchange_name = exchange_name
17
- @exchange_options = DEFAULT_EXCHANGE_OPTIONS.merge(options)
18
- end
7
+ class << self
8
+ def abstract_class?
9
+ return @abstract_class == true if defined?(@abstract_class)
19
10
 
20
- def publish(message, **options)
21
- payload = if message.is_a?(String)
22
- options[:content_type] ||= "text/plain"
23
- message
24
- else
25
- options[:content_type] ||= "application/json"
26
- MultiJson.dump(message)
27
- end
28
-
29
- bunny.with_channel do |channel|
30
- exchange = channel.exchange(@exchange_name, @exchange_options)
31
- exchange.publish(
32
- payload,
33
- DEFAULT_PUBLISH_OPTIONS.merge(options)
11
+ instance_variable_get(:@definition).nil?
12
+ end
13
+
14
+ def abstract_class=(value)
15
+ @abstract_class = value
16
+ remove_instance_variable(:@definition) if instance_variable_defined?(:@definition)
17
+ end
18
+
19
+ def inherited(subclass)
20
+ super
21
+ subclass.abstract_class = false
22
+ end
23
+
24
+ def definition
25
+ return if abstract_class?
26
+ return @definition if defined?(@definition)
27
+
28
+ name = Primitive::String.new(to_s).underscore.split("/").last
29
+ @definition = Producers::Definition.new(exchange: name)
30
+ end
31
+
32
+ # Configures the producer, setting exchange and other options to be used by
33
+ # the publisher for sending messages.
34
+ #
35
+ # @param [Hash] opts The options to configure the producer with.
36
+ # @option opts [String, Hash] :exchange The name of the exchange to publish to.
37
+ # @option opts [Hash] :publish Default publish options (persistent, mandatory, immediate).
38
+ # @yield [definition] Optional block to further configure the producer.
39
+ # @yieldparam [Lepus::Producers::Definition] definition The definition object.
40
+ # @return [Lepus::Producers::Definition] The configured producer definition.
41
+ def configure(opts = {})
42
+ raise ArgumentError, "Cannot configure an abstract class" if abstract_class?
43
+
44
+ @definition = Producers::Definition.new(opts)
45
+ yield(@definition) if block_given?
46
+ @definition
47
+ end
48
+
49
+ def descendants # :nodoc:
50
+ descendants = []
51
+ ObjectSpace.each_object(singleton_class) do |k|
52
+ descendants.unshift k unless k == self
53
+ end
54
+ descendants.uniq
55
+ end
56
+
57
+ # Creates a publisher instance configured with this producer's settings.
58
+ # @return [Lepus::Publisher] A publisher instance ready to send messages.
59
+ def publisher
60
+ @publisher ||= Publisher.new(definition.exchange_name, **definition.exchange_options)
61
+ end
62
+
63
+ # Returns the middleware chain for this producer.
64
+ # @return [Lepus::Producers::MiddlewareChain]
65
+ def middleware_chain
66
+ @middleware_chain ||= Producers::MiddlewareChain.new
67
+ end
68
+
69
+ # Registers a middleware to this producer's chain.
70
+ #
71
+ # @param middleware [Symbol, String, Class<Lepus::Middleware>] The middleware to register.
72
+ # @param opts [Hash] Options passed to the middleware constructor.
73
+ # @return [Lepus::Producers::MiddlewareChain]
74
+ def use(middleware, opts = {})
75
+ middleware_chain.use(middleware, opts)
76
+ end
77
+
78
+ # Publishes a message using this producer's configuration.
79
+ # Executes the middleware chain (global + per-producer) before publishing.
80
+ #
81
+ # @param payload [String, Hash] The message payload to publish.
82
+ # @param options [Hash] Additional publish options (routing_key, headers, etc.).
83
+ # @return [void]
84
+ def publish(payload, **options)
85
+ if definition.nil?
86
+ raise InvalidProducerConfigError, <<~ERROR
87
+ The #{name} producer is not configured.
88
+ Please call #{name}.configure before using #{self.class.name}.publish.
89
+ ERROR
90
+ end
91
+
92
+ return unless Producers.enabled?(self)
93
+
94
+ publish_opts = definition.publish_options.merge(options)
95
+ message = build_message(payload, publish_opts)
96
+ combined_chain = MiddlewareChain.combine(
97
+ Lepus.config.producer_middleware_chain,
98
+ middleware_chain
34
99
  )
100
+
101
+ combined_chain.execute(message) do |msg|
102
+ publisher.publish(msg.payload, **msg.to_publish_options)
103
+ end
35
104
  end
105
+
106
+ private
107
+
108
+ def build_message(payload, options)
109
+ opts = options.dup
110
+ routing_key = opts.delete(:routing_key)
111
+ headers = opts.delete(:headers)
112
+
113
+ delivery_info = Message::DeliveryInfo.new(
114
+ exchange: definition.exchange_name,
115
+ routing_key: routing_key
116
+ )
117
+
118
+ metadata = Message::Metadata.new(
119
+ headers: headers,
120
+ content_type: opts.delete(:content_type),
121
+ content_encoding: opts.delete(:content_encoding),
122
+ correlation_id: opts.delete(:correlation_id),
123
+ reply_to: opts.delete(:reply_to),
124
+ expiration: opts.delete(:expiration),
125
+ message_id: opts.delete(:message_id),
126
+ timestamp: opts.delete(:timestamp),
127
+ type: opts.delete(:type),
128
+ app_id: opts.delete(:app_id),
129
+ priority: opts.delete(:priority),
130
+ delivery_mode: opts.delete(:delivery_mode)
131
+ )
132
+
133
+ # Remaining options (persistent, mandatory, etc.) are passed as publish_options
134
+ Message.new(delivery_info, metadata, payload, publish_options: opts)
135
+ end
136
+ end
137
+
138
+ # Instance methods for when you need to work with producer instances
139
+ def initialize
140
+ @definition = self.class.definition
36
141
  end
37
142
 
38
- def bunny
39
- Thread.current[:lepus_bunny] ||= Lepus.config.create_connection(suffix: "producer")
143
+ attr_reader :definition
144
+
145
+ def publisher
146
+ @publisher ||= Publisher.new(definition.exchange_name, **definition.exchange_options)
147
+ end
148
+
149
+ def publish(message, **options)
150
+ self.class.publish(message, **options)
40
151
  end
41
152
  end
42
153
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Lepus
6
+ module Producers
7
+ # Configuration class for producer settings
8
+ class Config
9
+ extend Forwardable
10
+
11
+ DEFAULT_POOL_SIZE = 1
12
+ DEFAULT_POOL_TIMEOUT = 5.0
13
+
14
+ attr_accessor :pool_size, :pool_timeout
15
+
16
+ def_delegator :connection_pool, :with_connection
17
+
18
+ def initialize
19
+ @pool_size = DEFAULT_POOL_SIZE
20
+ @pool_timeout = DEFAULT_POOL_TIMEOUT
21
+ end
22
+
23
+ # Assign multiple attributes at once from a hash of options.
24
+ # @param options [Hash] hash of options to assign
25
+ # @return [void]
26
+ def assign(options = {})
27
+ options.each do |key, value|
28
+ raise ArgumentError, "Unknown attribute #{key}" unless respond_to?(:"#{key}=")
29
+
30
+ public_send(:"#{key}=", value)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # @return [Lepus::ConnectionPool] a connection pool instance configured for producers
37
+ def connection_pool
38
+ @connection_pool ||= Lepus::ConnectionPool.new(
39
+ size: pool_size,
40
+ timeout: pool_timeout,
41
+ suffix: "producer"
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end