eh_messaging 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f0602e56bb0054195d21d3ef88d5e9a784d7bd8462a4c0021e87c675b13687f
4
+ data.tar.gz: 6a2d3936b301d919f68b36043cb68681291e1e955b46bb7cb39b10c4ebc9acdb
5
+ SHA512:
6
+ metadata.gz: a6dd61080c1409691c49d0d38384041b54fbdf851037b82a1c54deabfc6fea9c4f046f2895c770ae715ce9b2b39fad6ba6c36136169733278a7c2d78dd43d4f8
7
+ data.tar.gz: dba29ccd76b533621e61c18be2190cdc2a7bf954549eaaec04d02149ac644f136a5fe3fabd77d7e71feb3684a10cf17cfdadc35869927854feb3e8c98f056b5c
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ require_relative "lib/eh_messaging/version"
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = "eh_messaging"
6
+ spec.version = EhMessaging::VERSION
7
+ spec.authors = ["Diego E"]
8
+
9
+ spec.summary = "Ruby library for Azure Event Hubs over the Kafka-compatible protocol"
10
+ spec.description = "A Ruby library for producing and consuming messages on Azure Event Hubs using its Kafka-compatible endpoint"
11
+ spec.required_ruby_version = ">= 3.4.0"
12
+
13
+ spec.files = Dir.chdir(__dir__) do
14
+ (Dir.glob("lib/**/*.rb") + Dir.glob("sig/**/*.rbs") + %w[eh_messaging.gemspec]).select { |file| File.file?(file) }
15
+ end
16
+ spec.bindir = "exe"
17
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
18
+ spec.require_paths = ["lib"]
19
+
20
+ # Dependencies
21
+ # `logger` is no longer guaranteed to be bundled with Ruby starting with
22
+ # Ruby 4.0. Declare it explicitly so applications get a stable dependency.
23
+ spec.add_dependency "logger", ">= 1.6", "< 2.0"
24
+ spec.add_dependency "opentelemetry-api", ">= 1.3", "< 2.0"
25
+ spec.add_dependency "rdkafka", "~> 0.29.0"
26
+
27
+ # Development dependencies
28
+ spec.add_development_dependency "rake", "~> 13.0"
29
+ spec.add_development_dependency "rspec", "~> 3.0"
30
+ spec.add_development_dependency "rbs", "~> 4.2"
31
+ spec.add_development_dependency "standard", "~> 1.43"
32
+ spec.add_development_dependency "steep", "~> 2.1"
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class AuditLogger
5
+ LEVELS = {
6
+ "CRITICAL" => Logger::FATAL,
7
+ "ERROR" => Logger::ERROR,
8
+ "WARNING" => Logger::WARN,
9
+ "INFO" => Logger::INFO,
10
+ "DEBUG" => Logger::DEBUG
11
+ }.freeze
12
+
13
+ def initialize(configuration)
14
+ @configuration = configuration
15
+ @logger = configuration.logger || rails_logger || Logger.new($stdout)
16
+ @logger.level = LEVELS.fetch(configuration.log_level.to_s, Logger::INFO) if @logger.respond_to?(:level=)
17
+ end
18
+
19
+ def info(message, fields = {}) = write(:info, message, fields)
20
+ def warn(message, fields = {}) = write(:warn, message, fields)
21
+ def error(message, fields = {}) = write(:error, message, fields)
22
+
23
+ def message(event, envelope:, error: nil)
24
+ fields = {
25
+ Key: envelope.partition_key,
26
+ TopicName: envelope.topic,
27
+ Offset: envelope.offset,
28
+ Partition: envelope.partition,
29
+ EnqueuedTime: envelope.enqueued_at&.iso8601,
30
+ ConsumerGroup: envelope.consumer_group,
31
+ Error: error&.message
32
+ }
33
+ envelope.headers.each do |key, value|
34
+ normalized_key = key.to_s
35
+ field = %w[operation-id parent-id].include?(normalized_key) ? normalized_key : "Header.#{normalized_key}"
36
+ fields[field] = value
37
+ end
38
+ fields[:MessageBody] = envelope.payload if @configuration.log_message_body
39
+ public_send(error ? :error : :info, event, fields)
40
+ end
41
+
42
+ private
43
+
44
+ def write(level, message, fields)
45
+ return unless @configuration.logging
46
+
47
+ payload = fields.empty? ? message : JSON.generate({event: message}.merge(sanitize(fields)))
48
+ @logger.public_send(level, payload)
49
+ end
50
+
51
+ def sanitize(fields)
52
+ fields.transform_keys(&:to_s).reject do |key, _|
53
+ key.match?(/password|secret|token|connection|string/i)
54
+ end
55
+ end
56
+
57
+ def rails_logger
58
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
59
+
60
+ Rails.logger
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class Client
5
+ def initialize
6
+ @configuration = nil
7
+ @publisher = nil
8
+ @subscriber = nil
9
+ @reconsume_subscribers = []
10
+ @booted = false
11
+ end
12
+
13
+ def configure(**options, &block)
14
+ raise Error, "EhMessaging is already configured" if @configuration
15
+
16
+ configuration = Configuration.new
17
+ configuration.apply(options)
18
+ block&.call(configuration)
19
+ configuration.validate!
20
+ @configuration = configuration
21
+ @publisher = Publisher.new(configuration, logger: AuditLogger.new(configuration))
22
+ @reconsume_subscribers = []
23
+ self
24
+ end
25
+
26
+ def configuration = @configuration || raise(Error, "EhMessaging is not configured")
27
+ def publish(topic, **options) = (@publisher || raise(Error, "EhMessaging is not configured")).publish(topic, **options)
28
+
29
+ def subscribe(handler)
30
+ configuration
31
+ @subscriber ||= Subscriber.new(configuration, logger: AuditLogger.new(configuration))
32
+ @subscriber.subscribe(handler)
33
+ end
34
+
35
+ def reconsume(handler, **options)
36
+ configuration
37
+ subscriber = Subscriber.new(configuration, producer: @publisher)
38
+ @reconsume_subscribers << subscriber
39
+ subscriber.reconsume(handler, **options)
40
+ ensure
41
+ @reconsume_subscribers&.delete(subscriber)
42
+ end
43
+
44
+ def boot
45
+ configuration
46
+ @publisher.boot
47
+ @subscriber&.boot
48
+ @booted = true
49
+ self
50
+ end
51
+
52
+ def run
53
+ boot unless @booted
54
+ @subscriber&.run
55
+ ensure
56
+ shutdown
57
+ end
58
+
59
+ def shutdown
60
+ @subscriber&.shutdown
61
+ @reconsume_subscribers&.each(&:shutdown)
62
+ @publisher&.shutdown
63
+ @booted = false
64
+ end
65
+
66
+ def reset!
67
+ shutdown
68
+ @configuration = @publisher = @subscriber = @reconsume_subscribers = nil
69
+ self
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class Configuration
5
+ PRODUCER_DEFAULTS = {"acks" => "all", "linger.ms" => 5, "message.send.max.retries" => 2,
6
+ "request.timeout.ms" => 30_000, "batch.num.messages" => 5_000, "partitioner" => "consistent_random",
7
+ "compression.type" => "none", "socket.keepalive.enable" => true, "metadata.max.age.ms" => 180_000}.freeze
8
+ CONSUMER_DEFAULTS = {"socket.timeout.ms" => 60_000, "session.timeout.ms" => 30_000,
9
+ "heartbeat.interval.ms" => 3_000, "max.poll.interval.ms" => 300_000, "metadata.max.age.ms" => 180_000,
10
+ "socket.keepalive.enable" => true, "auto.offset.reset" => "earliest",
11
+ "enable.auto.commit" => true}.freeze
12
+ FIXED = {"enable.auto.offset.store" => true, "allow.auto.create.topics" => false}.freeze
13
+ AUTO_OFFSET_RESETS = %w[earliest latest error].freeze
14
+ LOG_LEVELS = %w[CRITICAL ERROR WARNING INFO DEBUG].freeze
15
+ CONNECTION_KEYS = %i[host username password protocol sasl_mechanisms].freeze
16
+ PRODUCER_KEYS = %i[metadata_max_age_ms message_send_max_retries request_timeout_ms socket_keepalive_enable batch_num_messages linger_ms].freeze
17
+ CONSUMER_KEYS = %i[socket_timeout_ms session_timeout_ms heartbeat_interval_ms max_poll_interval_ms metadata_max_age_ms socket_keepalive_enable auto_offset_reset enable_auto_commit].freeze
18
+ attr_accessor :application_name, :logging, :log_message_body, :poll_timeout, :log_level, :logger
19
+ attr_reader :brokers, :topics, :producer, :consumer
20
+
21
+ def initialize
22
+ @brokers, @topics = {}, {}
23
+ @logging, @log_message_body, @poll_timeout, @log_level = true, true, 1, "INFO"
24
+ @producer, @consumer = ProducerSettings.new, ConsumerSettings.new
25
+ end
26
+
27
+ def apply(options)
28
+ if options.key?(:broker)
29
+ raise ArgumentError, "connection_string is required with broker shorthand" unless options.key?(:connection_string)
30
+ broker(:default, host: options[:broker], protocol: :sasl_ssl, username: "$ConnectionString", password: options[:connection_string])
31
+ end
32
+ allowed = %i[broker connection_string application_name logging log_message_body poll_timeout log_level logger]
33
+ unknown = options.keys - allowed
34
+ raise ArgumentError, "Unknown configuration: #{unknown.join(", ")}" unless unknown.empty?
35
+ options.except(:broker, :connection_string).each { |key, value| public_send("#{key}=", value) }
36
+ end
37
+
38
+ def broker(name, **options)
39
+ reject_unknown(options, CONNECTION_KEYS, "broker")
40
+ @brokers[name.to_sym] = {name: name.to_sym}.merge(options)
41
+ end
42
+
43
+ def topic(name, broker: nil, side: :both, **options)
44
+ raise ArgumentError, "topic cannot specify both broker and host" if broker && options.key?(:host)
45
+ reject_unknown(options, CONNECTION_KEYS, "topic")
46
+ raise ArgumentError, "invalid topic side" unless %i[produce consume both].include?(side)
47
+ @topics[name] = {broker: broker&.to_sym, side: side, name: (broker || name).to_sym}.merge(options)
48
+ end
49
+
50
+ def validate!
51
+ raise ArgumentError, "application_name is required" if application_name.to_s.empty?
52
+ raise ArgumentError, "default broker is required" unless @brokers[:default]
53
+ @brokers.each_value do |config|
54
+ raise ArgumentError, "broker host is required" if config[:host].to_s.empty?
55
+ raise ArgumentError, "broker protocol is required" if config[:protocol].nil?
56
+ end
57
+ raise ArgumentError, "invalid log_level" unless LOG_LEVELS.include?(log_level.to_s)
58
+ raise ArgumentError, "poll_timeout must be positive" unless poll_timeout.is_a?(Numeric) && poll_timeout.positive?
59
+ raise ArgumentError, "invalid auto_offset_reset" unless AUTO_OFFSET_RESETS.include?(consumer.auto_offset_reset)
60
+ validate_settings!
61
+ self
62
+ end
63
+
64
+ def broker_config(name = :default)
65
+ config = @brokers[name.to_sym]
66
+ raise ArgumentError, "Unknown broker: #{name}" unless config
67
+ config
68
+ end
69
+
70
+ def topic_config(name, side: nil)
71
+ config = @topics[name]
72
+ return broker_config if config.nil?
73
+ if side && config[:side] != :both && config[:side] != side
74
+ raise ArgumentError, "Topic #{name} is not configured for #{side}"
75
+ end
76
+ return broker_config(config[:broker]) if config[:broker]
77
+ broker_config.merge(config.except(:broker, :side))
78
+ end
79
+
80
+ def kafka_config(connection, kind: :producer, group_id: nil)
81
+ result = ((kind == :producer) ? @producer : @consumer).to_kafka.merge("bootstrap.servers" => connection.fetch(:host))
82
+ {protocol: "security.protocol", username: "sasl.username", password: "sasl.password", sasl_mechanisms: "sasl.mechanisms"}.each do |key, kafka_key|
83
+ result[kafka_key] = connection[key] if connection.key?(key)
84
+ end
85
+ result["group.id"] = group_id if group_id
86
+ result.merge((kind == :consumer) ? FIXED : {}).merge("allow.auto.create.topics" => false)
87
+ end
88
+
89
+ class Settings
90
+ def initialize(defaults)
91
+ @values = defaults.dup
92
+ end
93
+
94
+ def to_kafka = @values.dup
95
+
96
+ protected
97
+
98
+ def read(key) = @values.fetch(key)
99
+ def write(key, value) = @values[key] = value
100
+ end
101
+
102
+ class ProducerSettings < Settings
103
+ def initialize = super(PRODUCER_DEFAULTS)
104
+ def metadata_max_age_ms = read("metadata.max.age.ms")
105
+
106
+ def metadata_max_age_ms=(value)
107
+ write("metadata.max.age.ms", value)
108
+ end
109
+
110
+ def message_send_max_retries = read("message.send.max.retries")
111
+
112
+ def message_send_max_retries=(value)
113
+ write("message.send.max.retries", value)
114
+ end
115
+
116
+ def request_timeout_ms = read("request.timeout.ms")
117
+
118
+ def request_timeout_ms=(value)
119
+ write("request.timeout.ms", value)
120
+ end
121
+
122
+ def socket_keepalive_enable = read("socket.keepalive.enable")
123
+
124
+ def socket_keepalive_enable=(value)
125
+ write("socket.keepalive.enable", value)
126
+ end
127
+
128
+ def batch_num_messages = read("batch.num.messages")
129
+
130
+ def batch_num_messages=(value)
131
+ write("batch.num.messages", value)
132
+ end
133
+
134
+ def linger_ms = read("linger.ms")
135
+
136
+ def linger_ms=(value)
137
+ write("linger.ms", value)
138
+ end
139
+ end
140
+
141
+ class ConsumerSettings < Settings
142
+ def initialize = super(CONSUMER_DEFAULTS)
143
+ def socket_timeout_ms = read("socket.timeout.ms")
144
+
145
+ def socket_timeout_ms=(value)
146
+ write("socket.timeout.ms", value)
147
+ end
148
+
149
+ def session_timeout_ms = read("session.timeout.ms")
150
+
151
+ def session_timeout_ms=(value)
152
+ write("session.timeout.ms", value)
153
+ end
154
+
155
+ def heartbeat_interval_ms = read("heartbeat.interval.ms")
156
+
157
+ def heartbeat_interval_ms=(value)
158
+ write("heartbeat.interval.ms", value)
159
+ end
160
+
161
+ def max_poll_interval_ms = read("max.poll.interval.ms")
162
+
163
+ def max_poll_interval_ms=(value)
164
+ write("max.poll.interval.ms", value)
165
+ end
166
+
167
+ def metadata_max_age_ms = read("metadata.max.age.ms")
168
+
169
+ def metadata_max_age_ms=(value)
170
+ write("metadata.max.age.ms", value)
171
+ end
172
+
173
+ def socket_keepalive_enable = read("socket.keepalive.enable")
174
+
175
+ def socket_keepalive_enable=(value)
176
+ write("socket.keepalive.enable", value)
177
+ end
178
+
179
+ def auto_offset_reset = read("auto.offset.reset")
180
+
181
+ def auto_offset_reset=(value)
182
+ write("auto.offset.reset", value)
183
+ end
184
+
185
+ def enable_auto_commit = read("enable.auto.commit")
186
+
187
+ def enable_auto_commit=(value)
188
+ write("enable.auto.commit", value)
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ def validate_settings!
195
+ @producer.to_kafka.each_value { |value| raise ArgumentError, "producer settings cannot be nil" if value.nil? }
196
+ @consumer.to_kafka.each_value { |value| raise ArgumentError, "consumer settings cannot be nil" if value.nil? }
197
+ end
198
+
199
+ def reject_unknown(options, allowed, kind)
200
+ unknown = options.keys - allowed
201
+ raise ArgumentError, "Unknown #{kind} option: #{unknown.join(", ")}" unless unknown.empty?
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class DeadLetterRouter
5
+ def initialize(configuration, producer:, logger:)
6
+ @configuration, @producer, @logger = configuration, producer, logger
7
+ end
8
+
9
+ def route(handler, envelope, error)
10
+ publisher = @producer || Publisher.new(@configuration)
11
+ owns_publisher = @producer.nil?
12
+ broker = handler.respond_to?(:dead_letter_broker) ? handler.dead_letter_broker : :default
13
+ topic = Topics.dead_letter(handler.topic, envelope.consumer_group)
14
+ return @logger.warn("EhMessaging: DLT topic unavailable: #{topic}") unless publisher.topic_exists?(topic, broker: broker)
15
+
16
+ report = publisher.publish_dead_letter(handler.topic, envelope, error, broker: broker)
17
+ @logger.error("EhMessaging: DLT delivery failed: #{report.error.message}") if report.respond_to?(:error) && report.error
18
+ rescue => publish_error
19
+ @logger.error("EhMessaging: DLT routing failed: #{publish_error.class}: #{publish_error.message}")
20
+ ensure
21
+ publisher&.shutdown if owns_publisher
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class Envelope
5
+ STANDARD_HEADERS = %w[EVENT_NAME APPLICATION_NAME COMPONENT_NAME CORRELATION_ID VERSION EVENT_ID TIMESTAMP].freeze
6
+ attr_reader :payload, :partition_key, :headers, :event_name, :event_id, :correlation_id,
7
+ :application_name, :component_name, :version, :timestamp, :enqueued_at,
8
+ :topic, :partition, :offset, :consumer_group, :mode
9
+
10
+ def initialize(payload:, partition_key:, headers:, mode: nil, consumer_group: nil, topic: nil,
11
+ partition: nil, offset: nil, enqueued_at: nil)
12
+ @payload, @partition_key, @headers = payload, partition_key, headers
13
+ @mode, @consumer_group, @topic = mode, consumer_group, topic
14
+ @partition, @offset, @enqueued_at = partition, offset, enqueued_at&.utc
15
+ @event_name = self.class.header(headers, "EVENT_NAME")
16
+ @event_id = self.class.header(headers, "EVENT_ID")
17
+ @correlation_id = self.class.header(headers, "CORRELATION_ID")
18
+ @application_name = self.class.header(headers, "APPLICATION_NAME")
19
+ @component_name = self.class.header(headers, "COMPONENT_NAME")
20
+ @version = self.class.header(headers, "VERSION")
21
+ @timestamp = self.class.header(headers, "TIMESTAMP")
22
+ end
23
+
24
+ def self.header(headers, key)
25
+ value = headers[key] || headers[key.to_s] || headers[key.to_sym]
26
+ decode(value)
27
+ end
28
+
29
+ def self.from_message(message, mode:, consumer_group:)
30
+ headers = (message.headers || {}).each_with_object({}) { |(key, value), result| result[key.to_s] = value }
31
+ payload = JSON.parse(message.payload)
32
+ timestamp = message.respond_to?(:timestamp) ? message.timestamp : nil
33
+ timestamp = Time.at(timestamp / 1000.0).utc if timestamp.is_a?(Numeric)
34
+ new(payload: payload, partition_key: message.key, headers: headers, mode: mode,
35
+ consumer_group: consumer_group, topic: message.topic, partition: message.partition,
36
+ offset: message.offset, enqueued_at: timestamp)
37
+ end
38
+
39
+ def self.headers_from_message(message)
40
+ (message.headers || {}).each_with_object({}) { |(key, value), result| result[key.to_s] = value }
41
+ end
42
+
43
+ def self.decode(value)
44
+ return nil if value.nil?
45
+ return value unless value.is_a?(String)
46
+ parsed = JSON.parse(value)
47
+ parsed.is_a?(String) ? parsed : value
48
+ rescue JSON::ParserError
49
+ value
50
+ end
51
+
52
+ def self.header_bytes(value)
53
+ return value.map { |item| header_bytes(item) } if value.is_a?(Array)
54
+
55
+ (value.is_a?(String) ? value : JSON.generate(value)).dup.force_encoding(Encoding::UTF_8)
56
+ end
57
+
58
+ def self.timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
59
+
60
+ def self.headers(event:, application_name:, component:, correlation_id:, version:, headers: {})
61
+ generated = {
62
+ "EVENT_NAME" => event, "APPLICATION_NAME" => application_name, "COMPONENT_NAME" => component,
63
+ "CORRELATION_ID" => correlation_id, "VERSION" => version, "EVENT_ID" => SecureRandom.uuid,
64
+ "TIMESTAMP" => timestamp
65
+ }
66
+ generated.merge(headers.transform_keys(&:to_s))
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class DeliveryReport
5
+ attr_reader :raw, :sent_keys, :error_keys
6
+
7
+ def initialize(raw, key)
8
+ @raw = raw
9
+ @sent_keys = raw.error ? [] : [key]
10
+ @error_keys = raw.error ? [key] : []
11
+ end
12
+
13
+ def error = raw.error
14
+
15
+ def topic_name
16
+ raw.topic_name if raw.respond_to?(:topic_name)
17
+ end
18
+
19
+ def partition
20
+ raw.partition if raw.respond_to?(:partition)
21
+ end
22
+
23
+ def offset
24
+ raw.offset if raw.respond_to?(:offset)
25
+ end
26
+ end
27
+
28
+ class Publisher
29
+ REQUIRED = %i[event application_name component correlation_id version partition_key payload].freeze
30
+ attr_reader :producers
31
+
32
+ def initialize(configuration, producer_factory: nil, logger: nil)
33
+ @configuration, @producer_factory, @producers, @mutex = configuration, producer_factory, {}, Mutex.new
34
+ @logger = logger || AuditLogger.new(configuration)
35
+ @closed = false
36
+ end
37
+
38
+ def boot
39
+ ensure_open
40
+ @configuration.brokers.each_key { |name| producer(name) }
41
+ self
42
+ end
43
+
44
+ def publish(topic, **options)
45
+ ensure_open
46
+ validate_options(options)
47
+ connection = resolve_connection(topic, options[:broker])
48
+ header_options = options.slice(:event, :application_name, :component, :correlation_id, :version, :headers)
49
+ payload = options[:raw] ? options[:payload] : JSON.generate(options[:payload])
50
+ key = Envelope.header_bytes(options[:partition_key])
51
+ Telemetry.in_span("eh_messaging.publish", attributes: {"messaging.destination.name" => topic}) do |span|
52
+ headers = Telemetry.headers(Envelope.headers(**header_options), current_span: span)
53
+ report = producer(connection[:name], connection).produce(topic: topic, payload: payload, key: key,
54
+ headers: headers.transform_values { |value| Envelope.header_bytes(value) }).wait
55
+ delivery_report = DeliveryReport.new(report, options[:partition_key])
56
+ log_delivery(topic, options[:partition_key], delivery_report)
57
+ delivery_report
58
+ end
59
+ end
60
+
61
+ def publish_dead_letter(topic, envelope, error, broker: :default)
62
+ extras = envelope.headers.except(*Envelope::STANDARD_HEADERS)
63
+ payload = envelope.payload.is_a?(String) ? envelope.payload : JSON.generate(envelope.payload)
64
+ headers = Envelope.headers(event: envelope.event_name, application_name: envelope.application_name,
65
+ component: envelope.component_name, correlation_id: envelope.correlation_id, version: envelope.version,
66
+ headers: extras)
67
+ publish_raw(Topics.dead_letter(topic, envelope.consumer_group), payload, envelope.partition_key,
68
+ Telemetry.headers(headers), broker: broker)
69
+ end
70
+
71
+ def publish_raw(topic, payload, key, headers, broker: :default)
72
+ ensure_open
73
+ connection = resolve_connection(topic, broker)
74
+ report = producer(connection[:name], connection).produce(topic: topic, payload: payload, key: key,
75
+ headers: headers.transform_values { |value| Envelope.header_bytes(value) }).wait
76
+ DeliveryReport.new(report, key)
77
+ end
78
+
79
+ def topic_exists?(topic, broker: :default)
80
+ connection = @configuration.broker_config(broker)
81
+ metadata = producer(connection[:name], connection).metadata(topic)
82
+ topics = metadata.topics
83
+ topics = topics.keys if topics.is_a?(Hash)
84
+ Array(topics).any? do |item|
85
+ if item.is_a?(String)
86
+ item == topic
87
+ elsif item.respond_to?(:name)
88
+ item.name == topic
89
+ elsif item.is_a?(Hash)
90
+ item[:topic_name] == topic || item["topic_name"] == topic || item[:name] == topic || item["name"] == topic
91
+ else
92
+ item.respond_to?(:topic_name) && item.topic_name == topic
93
+ end
94
+ end
95
+ end
96
+
97
+ def shutdown
98
+ return self if @closed
99
+ @producers.each_value { |producer|
100
+ producer.flush if producer.respond_to?(:flush)
101
+ producer.close
102
+ }
103
+ @producers.clear
104
+ @closed = true
105
+ self
106
+ end
107
+
108
+ private
109
+
110
+ def ensure_open
111
+ raise Error, "publisher is shut down" if @closed
112
+ end
113
+
114
+ def producer(name, config = nil)
115
+ @mutex.synchronize do
116
+ @producers[name] ||= if @producer_factory
117
+ @producer_factory.call(name, config || @configuration.broker_config(name))
118
+ else
119
+ Rdkafka::Config.new(@configuration.kafka_config(config || @configuration.broker_config(name))).producer
120
+ end
121
+ end
122
+ end
123
+
124
+ def resolve_connection(topic, requested)
125
+ return @configuration.broker_config(requested) if requested.is_a?(Symbol)
126
+ if requested
127
+ return @configuration.brokers.values.find { |config| config[:host] == requested } ||
128
+ raise(ArgumentError, "Unknown broker host: #{requested}")
129
+ end
130
+ @configuration.topic_config(topic, side: :produce)
131
+ end
132
+
133
+ def validate_options(options)
134
+ missing = REQUIRED.reject { |key| options.key?(key) && !options[key].nil? }
135
+ raise ArgumentError, "Missing publish options: #{missing.join(", ")}" unless missing.empty?
136
+ allowed = REQUIRED + %i[headers broker raw]
137
+ unknown = options.keys - allowed
138
+ raise ArgumentError, "Unknown publish options: #{unknown.join(", ")}" unless unknown.empty?
139
+ raise ArgumentError, "headers must be a Hash" unless options.fetch(:headers, {}).is_a?(Hash)
140
+ raise ArgumentError, "payload must be a Hash, Array, or String" unless [Hash, Array, String].any? { |type| options[:payload].is_a?(type) }
141
+ end
142
+
143
+ def log_delivery(topic, key, report)
144
+ fields = {Key: key, TopicName: topic, Error: report.error&.message}
145
+ fields[:Offset] = report.offset if delivery_field?(report, :offset)
146
+ fields[:Partition] = report.partition if delivery_field?(report, :partition)
147
+ @logger.public_send(report.error ? :error : :info, "publish", fields)
148
+ end
149
+
150
+ def delivery_field?(report, name)
151
+ report.class.public_method_defined?(name, false)
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ class RecoverableError < StandardError; end
5
+ end