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 +7 -0
- data/eh_messaging.gemspec +33 -0
- data/lib/eh_messaging/audit_logger.rb +63 -0
- data/lib/eh_messaging/client.rb +72 -0
- data/lib/eh_messaging/configuration.rb +204 -0
- data/lib/eh_messaging/dead_letter_router.rb +24 -0
- data/lib/eh_messaging/envelope.rb +69 -0
- data/lib/eh_messaging/publisher.rb +154 -0
- data/lib/eh_messaging/recoverable_error.rb +5 -0
- data/lib/eh_messaging/subscriber.rb +300 -0
- data/lib/eh_messaging/subscriber_metadata.rb +87 -0
- data/lib/eh_messaging/subscriber_polling.rb +55 -0
- data/lib/eh_messaging/telemetry.rb +55 -0
- data/lib/eh_messaging/topics.rb +10 -0
- data/lib/eh_messaging/version.rb +5 -0
- data/lib/eh_messaging.rb +47 -0
- data/sig/eh_messaging.rbs +168 -0
- metadata +177 -0
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
|