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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EhMessaging
4
+ VERSION = "1.0.0"
5
+ end
@@ -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