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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EhMessaging
|
|
4
|
+
class Subscriber
|
|
5
|
+
attr_reader :subscriptions
|
|
6
|
+
|
|
7
|
+
def initialize(configuration, consumer_factory: nil, producer: nil, logger: nil)
|
|
8
|
+
@configuration, @consumer_factory, @producer, @logger = configuration, consumer_factory, producer, logger
|
|
9
|
+
@logger ||= AuditLogger.new(configuration)
|
|
10
|
+
@dead_letter_router = DeadLetterRouter.new(configuration, producer: producer, logger: @logger)
|
|
11
|
+
@subscriptions, @threads, @consumers, @mutex = [], [], [], Mutex.new
|
|
12
|
+
@stopped = ConditionVariable.new
|
|
13
|
+
@stopping = false
|
|
14
|
+
@booted = false
|
|
15
|
+
@closed = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def subscribe(handler)
|
|
19
|
+
raise Error, "subscriber is shut down" if @closed
|
|
20
|
+
raise Error, "cannot subscribe after boot" if @booted
|
|
21
|
+
raise ArgumentError, "handler must define topic and group" unless handler.respond_to?(:topic) && handler.respond_to?(:group)
|
|
22
|
+
@subscriptions << handler
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def boot
|
|
27
|
+
raise Error, "subscriber is shut down" if @closed
|
|
28
|
+
return self if @booted
|
|
29
|
+
@stopping = false
|
|
30
|
+
@subscriptions.each do |handler|
|
|
31
|
+
raise ArgumentError, "Topic #{handler.topic} is unavailable" unless topic_available?(handler, handler.topic, mode: :normal)
|
|
32
|
+
start_loop(handler, :normal, handler.topic, handler.group)
|
|
33
|
+
recovery_topic = Topics.recovery(handler.topic, handler.group)
|
|
34
|
+
start_loop(handler, :recovery, recovery_topic, Topics.recovery_group(handler.group)) if topic_available?(handler, recovery_topic, mode: :recovery)
|
|
35
|
+
end
|
|
36
|
+
@booted = true
|
|
37
|
+
self
|
|
38
|
+
rescue
|
|
39
|
+
shutdown_started_threads
|
|
40
|
+
raise
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reconsume(handler, until_time: nil, partitions: nil, group: nil)
|
|
44
|
+
raise Error, "subscriber is shut down" if @closed
|
|
45
|
+
raise ArgumentError, "exactly one of until_time or partitions is required" if until_time.nil? == partitions.nil?
|
|
46
|
+
group ||= Topics.reconsume_group(handler.group)
|
|
47
|
+
consumer = build_consumer(handler, :reconsume, handler.topic, group)
|
|
48
|
+
@mutex.synchronize { @consumers << consumer }
|
|
49
|
+
consumer.subscribe(handler.topic) if consumer.respond_to?(:subscribe)
|
|
50
|
+
deadline = until_time&.utc
|
|
51
|
+
ends = partitions&.to_h
|
|
52
|
+
completed = {}
|
|
53
|
+
if consumer.respond_to?(:assignment)
|
|
54
|
+
assigned = assigned_partitions(consumer, handler.topic)
|
|
55
|
+
if ends && !assigned.empty?
|
|
56
|
+
ends.each_key { |partition| completed[partition] = true unless assigned.include?(partition) }
|
|
57
|
+
elsif deadline
|
|
58
|
+
assigned.each { |partition| completed[partition] = false }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
until_complete = false
|
|
62
|
+
until (ends && completed.size == ends.size) || until_complete || @stopping
|
|
63
|
+
begin
|
|
64
|
+
message = poll_message(consumer)
|
|
65
|
+
rescue Rdkafka::RdkafkaError => error
|
|
66
|
+
log_poll_error(error)
|
|
67
|
+
break if @stopping || unknown_topic_error?(error) || closed_consumer_error?(error)
|
|
68
|
+
next
|
|
69
|
+
rescue => error
|
|
70
|
+
log_error(error)
|
|
71
|
+
break if @stopping || closed_consumer_error?(error)
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
unless message
|
|
75
|
+
if ends
|
|
76
|
+
assigned = assigned_partitions(consumer, handler.topic)
|
|
77
|
+
ends.each_key { |partition| completed[partition] = true unless assigned.include?(partition) } unless assigned.empty?
|
|
78
|
+
end
|
|
79
|
+
# EOF does not prove that a timestamp boundary has been crossed;
|
|
80
|
+
# a future UNTIL remains active until a later record or shutdown.
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
partition = message.partition
|
|
84
|
+
offset = message.offset
|
|
85
|
+
if ends
|
|
86
|
+
next unless ends.key?(partition)
|
|
87
|
+
if offset > ends.fetch(partition)
|
|
88
|
+
completed[partition] = true
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
completed[partition] = true if offset == ends.fetch(partition)
|
|
92
|
+
elsif deadline && broker_time(message) && broker_time(message) > deadline
|
|
93
|
+
completed[partition] = true
|
|
94
|
+
until_complete = completed.values.all? { |value| value }
|
|
95
|
+
@logger.info("Reconsume reached #{deadline.iso8601} on partition #{partition}")
|
|
96
|
+
break if until_complete
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
process_message(handler, :reconsume, group, message)
|
|
100
|
+
end
|
|
101
|
+
ensure
|
|
102
|
+
@mutex&.synchronize { @consumers.delete(consumer) if consumer }
|
|
103
|
+
consumer.close if consumer&.respond_to?(:close)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def run
|
|
107
|
+
@mutex.synchronize { @stopped.wait(@mutex) until @stopping }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def shutdown
|
|
111
|
+
@stopping = true
|
|
112
|
+
@mutex.synchronize { @stopped.broadcast }
|
|
113
|
+
close_registered_consumers
|
|
114
|
+
join_threads
|
|
115
|
+
clear_runtime
|
|
116
|
+
@booted = false
|
|
117
|
+
@closed = true
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def start_loop(handler, mode, topic, group)
|
|
124
|
+
thread = Thread.new do
|
|
125
|
+
consumer = nil
|
|
126
|
+
consumer = build_consumer(handler, mode, topic, group)
|
|
127
|
+
registered = @mutex.synchronize do
|
|
128
|
+
if @stopping
|
|
129
|
+
consumer.close if consumer.respond_to?(:close)
|
|
130
|
+
false
|
|
131
|
+
else
|
|
132
|
+
@consumers << consumer
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
next unless registered
|
|
137
|
+
consumer.subscribe(topic) if consumer.respond_to?(:subscribe)
|
|
138
|
+
unless mode == :normal || topic_available?(handler, topic, consumer: consumer)
|
|
139
|
+
@logger.warn("EhMessaging: topic unavailable: #{topic}")
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
run_polling_loop(handler, mode, group, consumer)
|
|
143
|
+
rescue => error
|
|
144
|
+
log_error(error)
|
|
145
|
+
ensure
|
|
146
|
+
close_consumer(consumer) if consumer
|
|
147
|
+
end
|
|
148
|
+
join_thread = @mutex.synchronize do
|
|
149
|
+
if @stopping
|
|
150
|
+
true
|
|
151
|
+
else
|
|
152
|
+
@threads << thread
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
thread.join if join_thread
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_consumer(handler, mode, topic, group)
|
|
160
|
+
broker_name = if mode == :recovery && handler.respond_to?(:recovery_broker)
|
|
161
|
+
handler.recovery_broker
|
|
162
|
+
else
|
|
163
|
+
handler.respond_to?(:broker) ? handler.broker : :default
|
|
164
|
+
end
|
|
165
|
+
connection = @configuration.broker_config(broker_name)
|
|
166
|
+
if @consumer_factory
|
|
167
|
+
@consumer_factory.call(handler, mode, topic, group, connection)
|
|
168
|
+
else
|
|
169
|
+
Rdkafka::Config.new(@configuration.kafka_config(connection, kind: :consumer, group_id: group)).consumer
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def process_message(handler, mode, group, message)
|
|
174
|
+
headers = (message.headers || {}).transform_keys(&:to_s)
|
|
175
|
+
begin
|
|
176
|
+
return if handler.respond_to?(:accept?) && !handler.accept?(headers)
|
|
177
|
+
rescue => error
|
|
178
|
+
log_error(error)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
begin
|
|
182
|
+
envelope = Envelope.from_message(message, mode: mode, consumer_group: group)
|
|
183
|
+
Telemetry.in_span("eh_messaging.consume", attributes: {
|
|
184
|
+
"messaging.destination.name" => message.topic,
|
|
185
|
+
"messaging.kafka.consumer.group" => group,
|
|
186
|
+
"messaging.kafka.partition" => message.partition,
|
|
187
|
+
"messaging.kafka.offset" => message.offset
|
|
188
|
+
}, parent_headers: envelope.headers) do |span|
|
|
189
|
+
handler.new.process(envelope)
|
|
190
|
+
span.set_status(:ok) if span.respond_to?(:set_status)
|
|
191
|
+
end
|
|
192
|
+
@logger.message("consume", envelope: envelope)
|
|
193
|
+
rescue JSON::ParserError => error
|
|
194
|
+
log_error(error)
|
|
195
|
+
envelope = invalid_envelope(message, mode, group)
|
|
196
|
+
@logger.message("consume", envelope: envelope, error: error)
|
|
197
|
+
publish_dlt(handler, envelope, error) if mode == :normal
|
|
198
|
+
rescue RecoverableError => error
|
|
199
|
+
log_error(error)
|
|
200
|
+
@logger.message("consume", envelope: envelope, error: error) if envelope
|
|
201
|
+
span&.record_exception(error) if defined?(span) && span.respond_to?(:record_exception)
|
|
202
|
+
rescue => error
|
|
203
|
+
log_error(error)
|
|
204
|
+
@logger.message("consume", envelope: envelope, error: error) if envelope
|
|
205
|
+
span&.record_exception(error) if defined?(span) && span.respond_to?(:record_exception)
|
|
206
|
+
publish_dlt(handler, envelope, error) if mode == :normal && envelope
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def invalid_envelope(message, mode, group)
|
|
211
|
+
headers = Envelope.headers_from_message(message)
|
|
212
|
+
timestamp = message.timestamp if message.respond_to?(:timestamp)
|
|
213
|
+
timestamp = Time.at(timestamp / 1000.0).utc if timestamp.is_a?(Numeric)
|
|
214
|
+
Envelope.new(payload: message.payload, partition_key: message.key, headers: headers,
|
|
215
|
+
mode: mode, consumer_group: group, topic: message.topic, partition: message.partition,
|
|
216
|
+
offset: message.offset, enqueued_at: timestamp)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def topic_available?(handler, topic, mode: :recovery, consumer: nil)
|
|
220
|
+
temporary_consumer = consumer.nil?
|
|
221
|
+
if @consumer_factory
|
|
222
|
+
return true if mode == :normal && consumer.nil?
|
|
223
|
+
|
|
224
|
+
group = (mode == :normal) ? handler.group : Topics.recovery_group(handler.group)
|
|
225
|
+
consumer ||= build_consumer(handler, mode, topic, group)
|
|
226
|
+
return true unless consumer.respond_to?(:topic_exists?)
|
|
227
|
+
return consumer.topic_exists?(topic)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
broker_method = (mode == :dlt) ? :dead_letter_broker : :recovery_broker
|
|
231
|
+
broker = handler.respond_to?(broker_method) ? handler.public_send(broker_method) : :default
|
|
232
|
+
connection = @configuration.broker_config(broker)
|
|
233
|
+
consumer ||= build_consumer(handler, mode, topic, (mode == :normal) ? handler.group : Topics.recovery_group(handler.group))
|
|
234
|
+
metadata = if consumer.respond_to?(:metadata)
|
|
235
|
+
consumer.metadata(topic)
|
|
236
|
+
else
|
|
237
|
+
admin = Rdkafka::Config.new(@configuration.kafka_config(connection)).admin
|
|
238
|
+
admin.metadata(topic)
|
|
239
|
+
end
|
|
240
|
+
metadata_topic_names(metadata).include?(topic)
|
|
241
|
+
rescue => error
|
|
242
|
+
@logger.warn("EhMessaging: topic unavailable: #{topic}: #{error.message}")
|
|
243
|
+
false
|
|
244
|
+
ensure
|
|
245
|
+
admin&.close
|
|
246
|
+
begin
|
|
247
|
+
consumer&.close if temporary_consumer
|
|
248
|
+
rescue
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def close_consumer(consumer)
|
|
254
|
+
registered = @mutex.synchronize { @consumers.delete(consumer) }
|
|
255
|
+
return unless registered
|
|
256
|
+
|
|
257
|
+
consumer.close if consumer.respond_to?(:close)
|
|
258
|
+
rescue => error
|
|
259
|
+
log_error(error)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def shutdown_started_threads
|
|
263
|
+
@stopping = true
|
|
264
|
+
close_registered_consumers
|
|
265
|
+
join_threads
|
|
266
|
+
clear_runtime
|
|
267
|
+
@booted = false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def close_registered_consumers
|
|
271
|
+
@mutex.synchronize { @consumers.dup }.each { |consumer| close_consumer(consumer) }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def join_threads
|
|
275
|
+
@mutex.synchronize { @threads.dup }.each(&:join)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def clear_runtime
|
|
279
|
+
@mutex.synchronize do
|
|
280
|
+
@threads.clear
|
|
281
|
+
@consumers.clear
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def publish_dlt(handler, envelope, error)
|
|
286
|
+
@dead_letter_router.route(handler, envelope, error)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def broker_time(message)
|
|
290
|
+
value = message.timestamp if message.respond_to?(:timestamp)
|
|
291
|
+
return nil if value.nil?
|
|
292
|
+
value.is_a?(Numeric) ? Time.at(value / 1000.0).utc : value.utc
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def log_poll_error(error) = @logger.error(error.message)
|
|
296
|
+
def log_error(error) = @logger.error("EhMessaging: #{error.class}: #{error.message}")
|
|
297
|
+
def unknown_topic_error?(error) = error.message.include?("UNKNOWN_TOPIC_OR_PART") || error.message.include?("Unknown topic")
|
|
298
|
+
def closed_consumer_error?(error) = error.class.name.to_s.include?("ClosedConsumer")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EhMessaging
|
|
4
|
+
class Subscriber
|
|
5
|
+
# Kafka metadata shapes vary slightly between rdkafka versions and test doubles.
|
|
6
|
+
module Metadata
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def assigned_partitions(consumer, topic)
|
|
10
|
+
assignment = consumer.assignment
|
|
11
|
+
partitions = if assignment.is_a?(Array)
|
|
12
|
+
assignment
|
|
13
|
+
elsif assignment.respond_to?(:to_h)
|
|
14
|
+
assignment.to_h.values.flatten
|
|
15
|
+
elsif assignment.respond_to?(:each)
|
|
16
|
+
assignment.each.to_a
|
|
17
|
+
else
|
|
18
|
+
Array(assignment)
|
|
19
|
+
end
|
|
20
|
+
assigned = partitions.filter_map do |partition|
|
|
21
|
+
next partition if partition.is_a?(Integer)
|
|
22
|
+
if partition.is_a?(Array)
|
|
23
|
+
partition.last
|
|
24
|
+
elsif partition.is_a?(Hash)
|
|
25
|
+
partition[:id] || partition["id"]
|
|
26
|
+
elsif partition.respond_to?(:partition)
|
|
27
|
+
partition.partition
|
|
28
|
+
elsif partition.respond_to?(:id)
|
|
29
|
+
partition.id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
return assigned unless assigned.empty? && consumer.respond_to?(:metadata)
|
|
33
|
+
|
|
34
|
+
metadata_partitions(consumer.metadata(topic), topic) || assigned
|
|
35
|
+
rescue => error
|
|
36
|
+
log_error(error)
|
|
37
|
+
assigned
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def metadata_partitions(metadata, requested_topic = nil)
|
|
41
|
+
topics = metadata.respond_to?(:topics) ? metadata.topics : metadata
|
|
42
|
+
topic = if topics.is_a?(Hash)
|
|
43
|
+
topics[requested_topic] || topics[requested_topic.to_s] || topics.values.first
|
|
44
|
+
else
|
|
45
|
+
Array(topics).find { |candidate| candidate.respond_to?(:name) && candidate.name == requested_topic } ||
|
|
46
|
+
Array(topics).find { |candidate| candidate.respond_to?(:topic_name) && candidate.topic_name == requested_topic } ||
|
|
47
|
+
Array(topics).first
|
|
48
|
+
end
|
|
49
|
+
partitions = if topic.respond_to?(:partitions)
|
|
50
|
+
topic.partitions
|
|
51
|
+
elsif topic.is_a?(Hash)
|
|
52
|
+
topic[:partitions] || topic["partitions"]
|
|
53
|
+
end
|
|
54
|
+
return unless partitions
|
|
55
|
+
|
|
56
|
+
return partitions.keys.filter_map { |partition| Integer(partition, exception: false) } if partitions.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
Array(partitions).filter_map do |partition|
|
|
59
|
+
next partition if partition.is_a?(Integer)
|
|
60
|
+
if partition.respond_to?(:id)
|
|
61
|
+
partition.id
|
|
62
|
+
elsif partition.is_a?(Hash)
|
|
63
|
+
partition[:id] || partition["id"]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def metadata_topic_names(metadata)
|
|
69
|
+
topics = metadata.topics
|
|
70
|
+
topics = topics.keys if topics.is_a?(Hash)
|
|
71
|
+
Array(topics).filter_map do |item|
|
|
72
|
+
if item.is_a?(String)
|
|
73
|
+
item
|
|
74
|
+
elsif item.respond_to?(:name)
|
|
75
|
+
item.name
|
|
76
|
+
elsif item.respond_to?(:topic_name)
|
|
77
|
+
item.topic_name
|
|
78
|
+
elsif item.is_a?(Hash)
|
|
79
|
+
item[:topic_name] || item["topic_name"] || item[:name] || item["name"]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
include Metadata
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EhMessaging
|
|
4
|
+
class Subscriber
|
|
5
|
+
# Kafka polling is isolated from lifecycle and message-routing concerns.
|
|
6
|
+
module Polling
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def run_polling_loop(handler, mode, group, consumer)
|
|
10
|
+
until @stopping
|
|
11
|
+
begin
|
|
12
|
+
stop_polling = poll_and_process(handler, mode, group, consumer)
|
|
13
|
+
break if stop_polling
|
|
14
|
+
rescue Rdkafka::RdkafkaError => error
|
|
15
|
+
log_poll_error(error)
|
|
16
|
+
break if @stopping || unknown_topic_error?(error) || closed_consumer_error?(error)
|
|
17
|
+
rescue => error
|
|
18
|
+
log_error(error)
|
|
19
|
+
break if @stopping || closed_consumer_error?(error)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def poll_and_process(handler, mode, group, consumer)
|
|
25
|
+
poll_messages(consumer).each do |message|
|
|
26
|
+
if message.is_a?(Rdkafka::RdkafkaError)
|
|
27
|
+
log_poll_error(message)
|
|
28
|
+
return true if @stopping || unknown_topic_error?(message) || closed_consumer_error?(message)
|
|
29
|
+
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
process_message(handler, mode, group, message)
|
|
33
|
+
end
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def poll_message(consumer)
|
|
38
|
+
timeout_ms = (@configuration.poll_timeout * 1000).to_i
|
|
39
|
+
return consumer.poll(timeout_ms) if consumer.respond_to?(:poll)
|
|
40
|
+
|
|
41
|
+
consumer.each.first
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def poll_messages(consumer)
|
|
45
|
+
timeout_ms = (@configuration.poll_timeout * 1000).to_i
|
|
46
|
+
return consumer.poll_batch(timeout_ms, max_items: 100) if consumer.respond_to?(:poll_batch)
|
|
47
|
+
|
|
48
|
+
message = poll_message(consumer)
|
|
49
|
+
message ? [message] : []
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
include Polling
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EhMessaging
|
|
4
|
+
module Telemetry
|
|
5
|
+
def self.tracer
|
|
6
|
+
OpenTelemetry.tracer_provider.tracer("eh_messaging", EhMessaging::VERSION)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.in_span(name, attributes: {}, parent_headers: nil)
|
|
10
|
+
parent_context = context_from_headers(parent_headers)
|
|
11
|
+
if parent_context
|
|
12
|
+
OpenTelemetry::Context.with_current(parent_context) do
|
|
13
|
+
tracer.in_span(name, attributes: attributes) { |span| yield span }
|
|
14
|
+
end
|
|
15
|
+
else
|
|
16
|
+
tracer.in_span(name, attributes: attributes) { |span| yield span }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.headers(headers, current_span: nil)
|
|
21
|
+
return headers if headers.key?("operation-id") && headers.key?("parent-id")
|
|
22
|
+
|
|
23
|
+
context = current_span&.context || OpenTelemetry::Trace.current_span.context
|
|
24
|
+
return fallback_headers(headers) unless context.respond_to?(:valid?) && context.valid?
|
|
25
|
+
|
|
26
|
+
headers.merge(
|
|
27
|
+
"operation-id" => context.hex_trace_id,
|
|
28
|
+
"parent-id" => context.hex_span_id
|
|
29
|
+
)
|
|
30
|
+
rescue
|
|
31
|
+
fallback_headers(headers)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.context_from_headers(headers)
|
|
35
|
+
return unless headers
|
|
36
|
+
operation_id = headers["operation-id"] || headers[:"operation-id"]
|
|
37
|
+
parent_id = headers["parent-id"] || headers[:"parent-id"]
|
|
38
|
+
return if operation_id.to_s.empty? || parent_id.to_s.empty?
|
|
39
|
+
|
|
40
|
+
span_context = OpenTelemetry::Trace::SpanContext.new(
|
|
41
|
+
trace_id: operation_id.to_s.delete("-").rjust(32, "0"),
|
|
42
|
+
span_id: parent_id.to_s.delete("-").rjust(16, "0"),
|
|
43
|
+
trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED,
|
|
44
|
+
remote: true
|
|
45
|
+
)
|
|
46
|
+
OpenTelemetry::Trace.context_with_span(OpenTelemetry::Trace.non_recording_span(span_context))
|
|
47
|
+
rescue
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.fallback_headers(headers)
|
|
52
|
+
headers.merge("operation-id" => SecureRandom.hex(16), "parent-id" => SecureRandom.hex(8))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EhMessaging
|
|
4
|
+
module Topics
|
|
5
|
+
def self.dead_letter(topic, group) = "#{topic}.#{group}.dlt"
|
|
6
|
+
def self.recovery(topic, group) = "#{topic}.#{group}.rec"
|
|
7
|
+
def self.recovery_group(group) = "#{group}_rec"
|
|
8
|
+
def self.reconsume_group(group) = "#{group}_reconsume"
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/eh_messaging.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EhMessaging
|
|
4
|
+
require "json"
|
|
5
|
+
require "logger"
|
|
6
|
+
require "opentelemetry-api"
|
|
7
|
+
require "rdkafka"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
require_relative "eh_messaging/version"
|
|
14
|
+
require_relative "eh_messaging/recoverable_error"
|
|
15
|
+
require_relative "eh_messaging/telemetry"
|
|
16
|
+
require_relative "eh_messaging/audit_logger"
|
|
17
|
+
require_relative "eh_messaging/topics"
|
|
18
|
+
require_relative "eh_messaging/envelope"
|
|
19
|
+
require_relative "eh_messaging/configuration"
|
|
20
|
+
require_relative "eh_messaging/publisher"
|
|
21
|
+
require_relative "eh_messaging/dead_letter_router"
|
|
22
|
+
require_relative "eh_messaging/subscriber"
|
|
23
|
+
require_relative "eh_messaging/subscriber_polling"
|
|
24
|
+
require_relative "eh_messaging/subscriber_metadata"
|
|
25
|
+
require_relative "eh_messaging/client"
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def configure(**options, &block) = client.configure(**options, &block) && self
|
|
29
|
+
def configuration = client.configuration
|
|
30
|
+
def publish(topic, **options) = client.publish(topic, **options)
|
|
31
|
+
def subscribe(handler) = client.subscribe(handler)
|
|
32
|
+
def reconsume(handler, **options) = client.reconsume(handler, **options)
|
|
33
|
+
def boot = client.boot && self
|
|
34
|
+
def run = client.run
|
|
35
|
+
def shutdown = client.shutdown
|
|
36
|
+
|
|
37
|
+
def reset!
|
|
38
|
+
client.reset!
|
|
39
|
+
@client = nil
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def client = @client ||= Client.new
|
|
46
|
+
end
|
|
47
|
+
end
|