ruby-kafka-aws-iam 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +393 -0
  3. data/.github/workflows/stale.yml +19 -0
  4. data/.gitignore +13 -0
  5. data/.readygo +1 -0
  6. data/.rspec +3 -0
  7. data/.rubocop.yml +44 -0
  8. data/.ruby-version +1 -0
  9. data/.yardopts +3 -0
  10. data/CHANGELOG.md +314 -0
  11. data/Gemfile +5 -0
  12. data/ISSUE_TEMPLATE.md +23 -0
  13. data/LICENSE.txt +176 -0
  14. data/Procfile +2 -0
  15. data/README.md +1356 -0
  16. data/Rakefile +8 -0
  17. data/benchmarks/message_encoding.rb +23 -0
  18. data/bin/console +8 -0
  19. data/bin/setup +5 -0
  20. data/docker-compose.yml +39 -0
  21. data/examples/consumer-group.rb +35 -0
  22. data/examples/firehose-consumer.rb +64 -0
  23. data/examples/firehose-producer.rb +54 -0
  24. data/examples/simple-consumer.rb +34 -0
  25. data/examples/simple-producer.rb +42 -0
  26. data/examples/ssl-producer.rb +44 -0
  27. data/lib/kafka/async_producer.rb +297 -0
  28. data/lib/kafka/broker.rb +217 -0
  29. data/lib/kafka/broker_info.rb +16 -0
  30. data/lib/kafka/broker_pool.rb +41 -0
  31. data/lib/kafka/broker_uri.rb +43 -0
  32. data/lib/kafka/client.rb +838 -0
  33. data/lib/kafka/cluster.rb +513 -0
  34. data/lib/kafka/compression.rb +45 -0
  35. data/lib/kafka/compressor.rb +86 -0
  36. data/lib/kafka/connection.rb +228 -0
  37. data/lib/kafka/connection_builder.rb +33 -0
  38. data/lib/kafka/consumer.rb +642 -0
  39. data/lib/kafka/consumer_group/assignor.rb +63 -0
  40. data/lib/kafka/consumer_group.rb +231 -0
  41. data/lib/kafka/crc32_hash.rb +15 -0
  42. data/lib/kafka/datadog.rb +420 -0
  43. data/lib/kafka/digest.rb +22 -0
  44. data/lib/kafka/fetch_operation.rb +115 -0
  45. data/lib/kafka/fetched_batch.rb +58 -0
  46. data/lib/kafka/fetched_batch_generator.rb +120 -0
  47. data/lib/kafka/fetched_message.rb +48 -0
  48. data/lib/kafka/fetched_offset_resolver.rb +48 -0
  49. data/lib/kafka/fetcher.rb +224 -0
  50. data/lib/kafka/gzip_codec.rb +34 -0
  51. data/lib/kafka/heartbeat.rb +25 -0
  52. data/lib/kafka/instrumenter.rb +38 -0
  53. data/lib/kafka/interceptors.rb +33 -0
  54. data/lib/kafka/lz4_codec.rb +27 -0
  55. data/lib/kafka/message_buffer.rb +87 -0
  56. data/lib/kafka/murmur2_hash.rb +17 -0
  57. data/lib/kafka/offset_manager.rb +259 -0
  58. data/lib/kafka/partitioner.rb +40 -0
  59. data/lib/kafka/pause.rb +92 -0
  60. data/lib/kafka/pending_message.rb +29 -0
  61. data/lib/kafka/pending_message_queue.rb +41 -0
  62. data/lib/kafka/produce_operation.rb +205 -0
  63. data/lib/kafka/producer.rb +528 -0
  64. data/lib/kafka/prometheus.rb +316 -0
  65. data/lib/kafka/protocol/add_offsets_to_txn_request.rb +29 -0
  66. data/lib/kafka/protocol/add_offsets_to_txn_response.rb +21 -0
  67. data/lib/kafka/protocol/add_partitions_to_txn_request.rb +34 -0
  68. data/lib/kafka/protocol/add_partitions_to_txn_response.rb +47 -0
  69. data/lib/kafka/protocol/alter_configs_request.rb +44 -0
  70. data/lib/kafka/protocol/alter_configs_response.rb +49 -0
  71. data/lib/kafka/protocol/api_versions_request.rb +21 -0
  72. data/lib/kafka/protocol/api_versions_response.rb +53 -0
  73. data/lib/kafka/protocol/consumer_group_protocol.rb +19 -0
  74. data/lib/kafka/protocol/create_partitions_request.rb +42 -0
  75. data/lib/kafka/protocol/create_partitions_response.rb +28 -0
  76. data/lib/kafka/protocol/create_topics_request.rb +45 -0
  77. data/lib/kafka/protocol/create_topics_response.rb +26 -0
  78. data/lib/kafka/protocol/decoder.rb +175 -0
  79. data/lib/kafka/protocol/delete_topics_request.rb +33 -0
  80. data/lib/kafka/protocol/delete_topics_response.rb +26 -0
  81. data/lib/kafka/protocol/describe_configs_request.rb +35 -0
  82. data/lib/kafka/protocol/describe_configs_response.rb +73 -0
  83. data/lib/kafka/protocol/describe_groups_request.rb +27 -0
  84. data/lib/kafka/protocol/describe_groups_response.rb +73 -0
  85. data/lib/kafka/protocol/encoder.rb +184 -0
  86. data/lib/kafka/protocol/end_txn_request.rb +29 -0
  87. data/lib/kafka/protocol/end_txn_response.rb +19 -0
  88. data/lib/kafka/protocol/fetch_request.rb +70 -0
  89. data/lib/kafka/protocol/fetch_response.rb +136 -0
  90. data/lib/kafka/protocol/find_coordinator_request.rb +29 -0
  91. data/lib/kafka/protocol/find_coordinator_response.rb +29 -0
  92. data/lib/kafka/protocol/heartbeat_request.rb +27 -0
  93. data/lib/kafka/protocol/heartbeat_response.rb +17 -0
  94. data/lib/kafka/protocol/init_producer_id_request.rb +26 -0
  95. data/lib/kafka/protocol/init_producer_id_response.rb +27 -0
  96. data/lib/kafka/protocol/join_group_request.rb +47 -0
  97. data/lib/kafka/protocol/join_group_response.rb +41 -0
  98. data/lib/kafka/protocol/leave_group_request.rb +25 -0
  99. data/lib/kafka/protocol/leave_group_response.rb +17 -0
  100. data/lib/kafka/protocol/list_groups_request.rb +23 -0
  101. data/lib/kafka/protocol/list_groups_response.rb +35 -0
  102. data/lib/kafka/protocol/list_offset_request.rb +53 -0
  103. data/lib/kafka/protocol/list_offset_response.rb +89 -0
  104. data/lib/kafka/protocol/member_assignment.rb +42 -0
  105. data/lib/kafka/protocol/message.rb +172 -0
  106. data/lib/kafka/protocol/message_set.rb +55 -0
  107. data/lib/kafka/protocol/metadata_request.rb +31 -0
  108. data/lib/kafka/protocol/metadata_response.rb +185 -0
  109. data/lib/kafka/protocol/offset_commit_request.rb +47 -0
  110. data/lib/kafka/protocol/offset_commit_response.rb +29 -0
  111. data/lib/kafka/protocol/offset_fetch_request.rb +38 -0
  112. data/lib/kafka/protocol/offset_fetch_response.rb +56 -0
  113. data/lib/kafka/protocol/produce_request.rb +94 -0
  114. data/lib/kafka/protocol/produce_response.rb +63 -0
  115. data/lib/kafka/protocol/record.rb +88 -0
  116. data/lib/kafka/protocol/record_batch.rb +223 -0
  117. data/lib/kafka/protocol/request_message.rb +26 -0
  118. data/lib/kafka/protocol/sasl_handshake_request.rb +33 -0
  119. data/lib/kafka/protocol/sasl_handshake_response.rb +28 -0
  120. data/lib/kafka/protocol/sync_group_request.rb +33 -0
  121. data/lib/kafka/protocol/sync_group_response.rb +26 -0
  122. data/lib/kafka/protocol/txn_offset_commit_request.rb +46 -0
  123. data/lib/kafka/protocol/txn_offset_commit_response.rb +47 -0
  124. data/lib/kafka/protocol.rb +225 -0
  125. data/lib/kafka/round_robin_assignment_strategy.rb +52 -0
  126. data/lib/kafka/sasl/awsmskiam.rb +128 -0
  127. data/lib/kafka/sasl/gssapi.rb +76 -0
  128. data/lib/kafka/sasl/oauth.rb +64 -0
  129. data/lib/kafka/sasl/plain.rb +39 -0
  130. data/lib/kafka/sasl/scram.rb +180 -0
  131. data/lib/kafka/sasl_authenticator.rb +73 -0
  132. data/lib/kafka/snappy_codec.rb +29 -0
  133. data/lib/kafka/socket_with_timeout.rb +96 -0
  134. data/lib/kafka/ssl_context.rb +66 -0
  135. data/lib/kafka/ssl_socket_with_timeout.rb +192 -0
  136. data/lib/kafka/statsd.rb +296 -0
  137. data/lib/kafka/tagged_logger.rb +77 -0
  138. data/lib/kafka/transaction_manager.rb +306 -0
  139. data/lib/kafka/transaction_state_machine.rb +72 -0
  140. data/lib/kafka/version.rb +5 -0
  141. data/lib/kafka/zstd_codec.rb +27 -0
  142. data/lib/kafka.rb +373 -0
  143. data/lib/ruby-kafka.rb +5 -0
  144. data/ruby-kafka.gemspec +54 -0
  145. metadata +520 -0
@@ -0,0 +1,642 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kafka/consumer_group"
4
+ require "kafka/interceptors"
5
+ require "kafka/offset_manager"
6
+ require "kafka/fetcher"
7
+ require "kafka/pause"
8
+
9
+ module Kafka
10
+
11
+ # A client that consumes messages from a Kafka cluster in coordination with
12
+ # other clients.
13
+ #
14
+ # A Consumer subscribes to one or more Kafka topics; all consumers with the
15
+ # same *group id* then agree on who should read from the individual topic
16
+ # partitions. When group members join or leave, the group synchronizes,
17
+ # making sure that all partitions are assigned to a single member, and that
18
+ # all members have some partitions to read from.
19
+ #
20
+ # ## Example
21
+ #
22
+ # A simple producer that simply writes the messages it consumes to the
23
+ # console.
24
+ #
25
+ # require "kafka"
26
+ #
27
+ # kafka = Kafka.new(["kafka1:9092", "kafka2:9092"])
28
+ #
29
+ # # Create a new Consumer instance in the group `my-group`:
30
+ # consumer = kafka.consumer(group_id: "my-group")
31
+ #
32
+ # # Subscribe to a Kafka topic:
33
+ # consumer.subscribe("messages")
34
+ #
35
+ # # Loop forever, reading in messages from all topics that have been
36
+ # # subscribed to.
37
+ # consumer.each_message do |message|
38
+ # puts message.topic
39
+ # puts message.partition
40
+ # puts message.key
41
+ # puts message.headers
42
+ # puts message.value
43
+ # puts message.offset
44
+ # end
45
+ #
46
+ class Consumer
47
+
48
+ def initialize(cluster:, logger:, instrumenter:, group:, fetcher:, offset_manager:,
49
+ session_timeout:, heartbeat:, refresh_topic_interval: 0, interceptors: [])
50
+ @cluster = cluster
51
+ @logger = TaggedLogger.new(logger)
52
+ @instrumenter = instrumenter
53
+ @group = group
54
+ @offset_manager = offset_manager
55
+ @session_timeout = session_timeout
56
+ @fetcher = fetcher
57
+ @heartbeat = heartbeat
58
+ @refresh_topic_interval = refresh_topic_interval
59
+ @interceptors = Interceptors.new(interceptors: interceptors, logger: logger)
60
+
61
+ @pauses = Hash.new {|h, k|
62
+ h[k] = Hash.new {|h2, k2|
63
+ h2[k2] = Pause.new
64
+ }
65
+ }
66
+
67
+ # Whether or not the consumer is currently consuming messages.
68
+ @running = false
69
+
70
+ # Hash containing offsets for each topic and partition that has the
71
+ # automatically_mark_as_processed feature disabled. Offset manager is only active
72
+ # when everything is suppose to happen automatically. Otherwise we need to keep track of the
73
+ # offset manually in memory for all the time
74
+ # The key structure for this equals an array with topic and partition [topic, partition]
75
+ # The value is equal to the offset of the last message we've received
76
+ # @note It won't be updated in case user marks message as processed, because for the case
77
+ # when user commits message other than last in a batch, this would make ruby-kafka refetch
78
+ # some already consumed messages
79
+ @current_offsets = Hash.new { |h, k| h[k] = {} }
80
+
81
+ # Map storing subscribed topics with their configuration
82
+ @subscribed_topics = Hash.new
83
+
84
+ # Set storing topics that matched topics in @subscribed_topics
85
+ @matched_topics = Set.new
86
+
87
+ # Whether join_group must be executed again because new topics are added
88
+ @join_group_for_new_topics = false
89
+ end
90
+
91
+ # Subscribes the consumer to a topic.
92
+ #
93
+ # Typically you either want to start reading messages from the very
94
+ # beginning of the topic's partitions or you simply want to wait for new
95
+ # messages to be written. In the former case, set `start_from_beginning`
96
+ # to true (the default); in the latter, set it to false.
97
+ #
98
+ # @param topic_or_regex [String, Regexp] subscribe to single topic with a string
99
+ # or multiple topics matching a regex.
100
+ # @param default_offset [Symbol] whether to start from the beginning or the
101
+ # end of the topic's partitions. Deprecated.
102
+ # @param start_from_beginning [Boolean] whether to start from the beginning
103
+ # of the topic or just subscribe to new messages being produced. This
104
+ # only applies when first consuming a topic partition – once the consumer
105
+ # has checkpointed its progress, it will always resume from the last
106
+ # checkpoint.
107
+ # @param max_bytes_per_partition [Integer] the maximum amount of data fetched
108
+ # from a single partition at a time.
109
+ # @return [nil]
110
+ def subscribe(topic_or_regex, default_offset: nil, start_from_beginning: true, max_bytes_per_partition: 1048576)
111
+ default_offset ||= start_from_beginning ? :earliest : :latest
112
+
113
+ @subscribed_topics[topic_or_regex] = {
114
+ default_offset: default_offset,
115
+ start_from_beginning: start_from_beginning,
116
+ max_bytes_per_partition: max_bytes_per_partition
117
+ }
118
+ scan_for_subscribing
119
+
120
+ nil
121
+ end
122
+
123
+ # Stop the consumer.
124
+ #
125
+ # The consumer will finish any in-progress work and shut down.
126
+ #
127
+ # @return [nil]
128
+ def stop
129
+ @running = false
130
+ @fetcher.stop
131
+ end
132
+
133
+ # Pause processing of a specific topic partition.
134
+ #
135
+ # When a specific message causes the processor code to fail, it can be a good
136
+ # idea to simply pause the partition until the error can be resolved, allowing
137
+ # the rest of the partitions to continue being processed.
138
+ #
139
+ # If the `timeout` argument is passed, the partition will automatically be
140
+ # resumed when the timeout expires. If `exponential_backoff` is enabled, each
141
+ # subsequent pause will cause the timeout to double until a message from the
142
+ # partition has been successfully processed.
143
+ #
144
+ # @param topic [String]
145
+ # @param partition [Integer]
146
+ # @param timeout [nil, Integer] the number of seconds to pause the partition for,
147
+ # or `nil` if the partition should not be automatically resumed.
148
+ # @param max_timeout [nil, Integer] the maximum number of seconds to pause for,
149
+ # or `nil` if no maximum should be enforced.
150
+ # @param exponential_backoff [Boolean] whether to enable exponential backoff.
151
+ # @return [nil]
152
+ def pause(topic, partition, timeout: nil, max_timeout: nil, exponential_backoff: false)
153
+ if max_timeout && !exponential_backoff
154
+ raise ArgumentError, "`max_timeout` only makes sense when `exponential_backoff` is enabled"
155
+ end
156
+
157
+ pause_for(topic, partition).pause!(
158
+ timeout: timeout,
159
+ max_timeout: max_timeout,
160
+ exponential_backoff: exponential_backoff,
161
+ )
162
+ end
163
+
164
+ # Resume processing of a topic partition.
165
+ #
166
+ # @see #pause
167
+ # @param topic [String]
168
+ # @param partition [Integer]
169
+ # @return [nil]
170
+ def resume(topic, partition)
171
+ pause_for(topic, partition).resume!
172
+
173
+ # During re-balancing we might have lost the paused partition. Check if partition is still in group before seek.
174
+ seek_to_next(topic, partition) if @group.assigned_to?(topic, partition)
175
+ end
176
+
177
+ # Whether the topic partition is currently paused.
178
+ #
179
+ # @see #pause
180
+ # @param topic [String]
181
+ # @param partition [Integer]
182
+ # @return [Boolean] true if the partition is paused, false otherwise.
183
+ def paused?(topic, partition)
184
+ pause = pause_for(topic, partition)
185
+ pause.paused? && !pause.expired?
186
+ end
187
+
188
+ # Fetches and enumerates the messages in the topics that the consumer group
189
+ # subscribes to.
190
+ #
191
+ # Each message is yielded to the provided block. If the block returns
192
+ # without raising an exception, the message will be considered successfully
193
+ # processed. At regular intervals the offset of the most recent successfully
194
+ # processed message in each partition will be committed to the Kafka
195
+ # offset store. If the consumer crashes or leaves the group, the group member
196
+ # that is tasked with taking over processing of these partitions will resume
197
+ # at the last committed offsets.
198
+ #
199
+ # @param min_bytes [Integer] the minimum number of bytes to read before
200
+ # returning messages from each broker; if `max_wait_time` is reached, this
201
+ # is ignored.
202
+ # @param max_bytes [Integer] the maximum number of bytes to read before
203
+ # returning messages from each broker.
204
+ # @param max_wait_time [Integer, Float] the maximum duration of time to wait before
205
+ # returning messages from each broker, in seconds.
206
+ # @param automatically_mark_as_processed [Boolean] whether to automatically
207
+ # mark a message as successfully processed when the block returns
208
+ # without an exception. Once marked successful, the offsets of processed
209
+ # messages can be committed to Kafka.
210
+ # @yieldparam message [Kafka::FetchedMessage] a message fetched from Kafka.
211
+ # @raise [Kafka::ProcessingError] if there was an error processing a message.
212
+ # The original exception will be returned by calling `#cause` on the
213
+ # {Kafka::ProcessingError} instance.
214
+ # @return [nil]
215
+ def each_message(min_bytes: 1, max_bytes: 10485760, max_wait_time: 1, automatically_mark_as_processed: true)
216
+ @fetcher.configure(
217
+ min_bytes: min_bytes,
218
+ max_bytes: max_bytes,
219
+ max_wait_time: max_wait_time,
220
+ )
221
+
222
+ consumer_loop do
223
+ batches = fetch_batches
224
+
225
+ batches.each do |batch|
226
+ batch = @interceptors.call(batch)
227
+ batch.messages.each do |message|
228
+ notification = {
229
+ topic: message.topic,
230
+ partition: message.partition,
231
+ offset: message.offset,
232
+ offset_lag: batch.highwater_mark_offset - message.offset - 1,
233
+ create_time: message.create_time,
234
+ key: message.key,
235
+ value: message.value,
236
+ headers: message.headers
237
+ }
238
+
239
+ # Instrument an event immediately so that subscribers don't have to wait until
240
+ # the block is completed.
241
+ @instrumenter.instrument("start_process_message.consumer", notification)
242
+
243
+ @instrumenter.instrument("process_message.consumer", notification) do
244
+ begin
245
+ yield message unless message.is_control_record
246
+ @current_offsets[message.topic][message.partition] = message.offset
247
+ rescue => e
248
+ location = "#{message.topic}/#{message.partition} at offset #{message.offset}"
249
+ backtrace = e.backtrace.join("\n")
250
+ @logger.error "Exception raised when processing #{location} -- #{e.class}: #{e}\n#{backtrace}"
251
+
252
+ raise ProcessingError.new(message.topic, message.partition, message.offset)
253
+ end
254
+ end
255
+
256
+ mark_message_as_processed(message) if automatically_mark_as_processed
257
+ @offset_manager.commit_offsets_if_necessary
258
+
259
+ trigger_heartbeat
260
+
261
+ return if shutting_down?
262
+ end
263
+
264
+ # We've successfully processed a batch from the partition, so we can clear
265
+ # the pause.
266
+ pause_for(batch.topic, batch.partition).reset!
267
+ end
268
+
269
+ # We may not have received any messages, but it's still a good idea to
270
+ # commit offsets if we've processed messages in the last set of batches.
271
+ # This also ensures the offsets are retained if we haven't read any messages
272
+ # since the offset retention period has elapsed.
273
+ @offset_manager.commit_offsets_if_necessary
274
+ end
275
+ end
276
+
277
+ # Fetches and enumerates the messages in the topics that the consumer group
278
+ # subscribes to.
279
+ #
280
+ # Each batch of messages is yielded to the provided block. If the block returns
281
+ # without raising an exception, the batch will be considered successfully
282
+ # processed. At regular intervals the offset of the most recent successfully
283
+ # processed message batch in each partition will be committed to the Kafka
284
+ # offset store. If the consumer crashes or leaves the group, the group member
285
+ # that is tasked with taking over processing of these partitions will resume
286
+ # at the last committed offsets.
287
+ #
288
+ # @param min_bytes [Integer] the minimum number of bytes to read before
289
+ # returning messages from each broker; if `max_wait_time` is reached, this
290
+ # is ignored.
291
+ # @param max_bytes [Integer] the maximum number of bytes to read before
292
+ # returning messages from each broker.
293
+ # @param max_wait_time [Integer, Float] the maximum duration of time to wait before
294
+ # returning messages from each broker, in seconds.
295
+ # @param automatically_mark_as_processed [Boolean] whether to automatically
296
+ # mark a batch's messages as successfully processed when the block returns
297
+ # without an exception. Once marked successful, the offsets of processed
298
+ # messages can be committed to Kafka.
299
+ # @yieldparam batch [Kafka::FetchedBatch] a message batch fetched from Kafka.
300
+ # @raise [Kafka::ProcessingError] if there was an error processing a batch.
301
+ # The original exception will be returned by calling `#cause` on the
302
+ # {Kafka::ProcessingError} instance.
303
+ # @return [nil]
304
+ def each_batch(min_bytes: 1, max_bytes: 10485760, max_wait_time: 1, automatically_mark_as_processed: true)
305
+ @fetcher.configure(
306
+ min_bytes: min_bytes,
307
+ max_bytes: max_bytes,
308
+ max_wait_time: max_wait_time,
309
+ )
310
+
311
+ consumer_loop do
312
+ batches = fetch_batches
313
+
314
+ batches.each do |batch|
315
+ unless batch.empty?
316
+ raw_messages = batch.messages
317
+ batch.messages = raw_messages.reject(&:is_control_record)
318
+ batch = @interceptors.call(batch)
319
+
320
+ notification = {
321
+ topic: batch.topic,
322
+ partition: batch.partition,
323
+ last_offset: batch.last_offset,
324
+ last_create_time: batch.messages.last && batch.messages.last.create_time,
325
+ offset_lag: batch.offset_lag,
326
+ highwater_mark_offset: batch.highwater_mark_offset,
327
+ message_count: batch.messages.count,
328
+ }
329
+
330
+ # Instrument an event immediately so that subscribers don't have to wait until
331
+ # the block is completed.
332
+ @instrumenter.instrument("start_process_batch.consumer", notification)
333
+
334
+ @instrumenter.instrument("process_batch.consumer", notification) do
335
+ begin
336
+ yield batch
337
+ @current_offsets[batch.topic][batch.partition] = batch.last_offset unless batch.unknown_last_offset?
338
+ rescue => e
339
+ offset_range = (batch.first_offset..batch.last_offset || batch.highwater_mark_offset)
340
+ location = "#{batch.topic}/#{batch.partition} in offset range #{offset_range}"
341
+ backtrace = e.backtrace.join("\n")
342
+
343
+ @logger.error "Exception raised when processing #{location} -- #{e.class}: #{e}\n#{backtrace}"
344
+
345
+ raise ProcessingError.new(batch.topic, batch.partition, offset_range)
346
+ ensure
347
+ batch.messages = raw_messages
348
+ end
349
+ end
350
+ mark_message_as_processed(batch.messages.last) if automatically_mark_as_processed
351
+
352
+ # We've successfully processed a batch from the partition, so we can clear
353
+ # the pause.
354
+ pause_for(batch.topic, batch.partition).reset!
355
+ end
356
+
357
+ @offset_manager.commit_offsets_if_necessary
358
+
359
+ trigger_heartbeat
360
+
361
+ return if shutting_down?
362
+ end
363
+
364
+ # We may not have received any messages, but it's still a good idea to
365
+ # commit offsets if we've processed messages in the last set of batches.
366
+ # This also ensures the offsets are retained if we haven't read any messages
367
+ # since the offset retention period has elapsed.
368
+ @offset_manager.commit_offsets_if_necessary
369
+ end
370
+ end
371
+
372
+ # Move the consumer's position in a topic partition to the specified offset.
373
+ #
374
+ # Note that this has to be done prior to calling {#each_message} or {#each_batch}
375
+ # and only has an effect if the consumer is assigned the partition. Typically,
376
+ # you will want to do this in every consumer group member in order to make sure
377
+ # that the member that's assigned the partition knows where to start.
378
+ #
379
+ # @param topic [String]
380
+ # @param partition [Integer]
381
+ # @param offset [Integer]
382
+ # @return [nil]
383
+ def seek(topic, partition, offset)
384
+ @offset_manager.seek_to(topic, partition, offset)
385
+ end
386
+
387
+ def commit_offsets
388
+ @offset_manager.commit_offsets
389
+ end
390
+
391
+ def mark_message_as_processed(message)
392
+ @offset_manager.mark_as_processed(message.topic, message.partition, message.offset)
393
+ end
394
+
395
+ def trigger_heartbeat
396
+ @heartbeat.trigger
397
+ end
398
+
399
+ def trigger_heartbeat!
400
+ @heartbeat.trigger!
401
+ end
402
+
403
+ # Aliases for the external API compatibility
404
+ alias send_heartbeat_if_necessary trigger_heartbeat
405
+ alias send_heartbeat trigger_heartbeat!
406
+
407
+ private
408
+
409
+ def consumer_loop
410
+ @running = true
411
+ @logger.push_tags(@group.to_s)
412
+
413
+ @fetcher.start
414
+
415
+ while running?
416
+ begin
417
+ @instrumenter.instrument("loop.consumer") do
418
+ refresh_topic_list_if_enabled
419
+ yield
420
+ end
421
+ rescue HeartbeatError
422
+ make_final_offsets_commit!
423
+ join_group if running?
424
+ rescue OffsetCommitError
425
+ join_group if running?
426
+ rescue RebalanceInProgress
427
+ @logger.warn "Group rebalance in progress, re-joining..."
428
+ join_group if running?
429
+ rescue FetchError, NotLeaderForPartition, UnknownTopicOrPartition
430
+ @cluster.mark_as_stale!
431
+ rescue LeaderNotAvailable => e
432
+ @logger.error "Leader not available; waiting 1s before retrying"
433
+ @cluster.mark_as_stale!
434
+ sleep 1
435
+ rescue ConnectionError => e
436
+ @logger.error "Connection error #{e.class}: #{e.message}"
437
+ @cluster.mark_as_stale!
438
+ rescue SignalException => e
439
+ @logger.warn "Received signal #{e.message}, shutting down"
440
+ @running = false
441
+ end
442
+ end
443
+ ensure
444
+ @fetcher.stop
445
+
446
+ # In order to quickly have the consumer group re-balance itself, it's
447
+ # important that members explicitly tell Kafka when they're leaving.
448
+ make_final_offsets_commit!
449
+ @group.leave rescue nil
450
+ @cluster.disconnect
451
+ @running = false
452
+ @logger.pop_tags
453
+ end
454
+
455
+ def make_final_offsets_commit!(attempts = 3)
456
+ @offset_manager.commit_offsets
457
+ rescue ConnectionError, OffsetCommitError, EOFError
458
+ # It's important to make sure final offsets commit is done
459
+ # As otherwise messages that have been processed after last auto-commit
460
+ # will be processed again and that may be huge amount of messages
461
+ return if attempts.zero?
462
+
463
+ @logger.error "Retrying to make final offsets commit (#{attempts} attempts left)"
464
+ sleep(0.1)
465
+ make_final_offsets_commit!(attempts - 1)
466
+ rescue Kafka::Error => e
467
+ @logger.error "Encountered error while shutting down; #{e.class}: #{e.message}"
468
+ end
469
+
470
+ def join_group
471
+ @join_group_for_new_topics = false
472
+
473
+ old_generation_id = @group.generation_id
474
+
475
+ @group.join
476
+
477
+ if old_generation_id && @group.generation_id != old_generation_id + 1
478
+ # We've been out of the group for at least an entire generation, no
479
+ # sense in trying to hold on to offset data
480
+ clear_current_offsets
481
+ @offset_manager.clear_offsets
482
+ else
483
+ # After rejoining the group we may have been assigned a new set of
484
+ # partitions. Keeping the old offset commits around forever would risk
485
+ # having the consumer go back and reprocess messages if it's assigned
486
+ # a partition it used to be assigned to way back. For that reason, we
487
+ # only keep commits for the partitions that we're still assigned.
488
+ clear_current_offsets(excluding: @group.assigned_partitions)
489
+ @offset_manager.clear_offsets_excluding(@group.assigned_partitions)
490
+ end
491
+
492
+ @fetcher.reset
493
+
494
+ @group.assigned_partitions.each do |topic, partitions|
495
+ partitions.each do |partition|
496
+ if paused?(topic, partition)
497
+ @logger.warn "Not fetching from #{topic}/#{partition} due to pause"
498
+ else
499
+ seek_to_next(topic, partition)
500
+ end
501
+ end
502
+ end
503
+ end
504
+
505
+ def seek_to_next(topic, partition)
506
+ # When automatic marking is off, the first poll needs to be based on the last committed
507
+ # offset from Kafka, that's why we fallback in case of nil (it may not be 0)
508
+ if @current_offsets[topic].key?(partition)
509
+ offset = @current_offsets[topic][partition] + 1
510
+ else
511
+ offset = @offset_manager.next_offset_for(topic, partition)
512
+ end
513
+
514
+ @fetcher.seek(topic, partition, offset)
515
+ end
516
+
517
+ def resume_paused_partitions!
518
+ @pauses.each do |topic, partitions|
519
+ partitions.each do |partition, pause|
520
+ @instrumenter.instrument("pause_status.consumer", {
521
+ topic: topic,
522
+ partition: partition,
523
+ duration: pause.pause_duration,
524
+ })
525
+
526
+ if pause.paused? && pause.expired?
527
+ @logger.info "Automatically resuming partition #{topic}/#{partition}, pause timeout expired"
528
+ resume(topic, partition)
529
+ end
530
+ end
531
+ end
532
+ end
533
+
534
+ def refresh_topic_list_if_enabled
535
+ return if @refresh_topic_interval <= 0
536
+ return if @refreshed_at && @refreshed_at + @refresh_topic_interval > Time.now
537
+
538
+ scan_for_subscribing
539
+ @refreshed_at = Time.now
540
+ end
541
+
542
+ def fetch_batches
543
+ # Return early if the consumer has been stopped.
544
+ return [] if shutting_down?
545
+
546
+ join_group if !@group.member? || @join_group_for_new_topics
547
+
548
+ trigger_heartbeat
549
+
550
+ resume_paused_partitions!
551
+
552
+ if !@fetcher.data?
553
+ @logger.debug "No batches to process"
554
+ sleep(@fetcher.max_wait_time || 2)
555
+ []
556
+ else
557
+ tag, message = @fetcher.poll
558
+
559
+ case tag
560
+ when :batches
561
+ # make sure any old batches, fetched prior to the completion of a consumer group sync,
562
+ # are only processed if the batches are from brokers for which this broker is still responsible.
563
+ message.select { |batch| @group.assigned_to?(batch.topic, batch.partition) }
564
+ when :exception
565
+ raise message
566
+ end
567
+ end
568
+ rescue OffsetOutOfRange => e
569
+ @logger.error "Invalid offset #{e.offset} for #{e.topic}/#{e.partition}, resetting to default offset"
570
+
571
+ @offset_manager.seek_to_default(e.topic, e.partition)
572
+
573
+ retry
574
+ rescue ConnectionError => e
575
+ @logger.error "Connection error while fetching messages: #{e}"
576
+
577
+ raise FetchError, e
578
+ end
579
+
580
+ def pause_for(topic, partition)
581
+ @pauses[topic][partition]
582
+ end
583
+
584
+ def running?
585
+ @running
586
+ end
587
+
588
+ def shutting_down?
589
+ !running?
590
+ end
591
+
592
+ def clear_current_offsets(excluding: {})
593
+ @current_offsets.each do |topic, partitions|
594
+ partitions.keep_if do |partition, _|
595
+ excluding.fetch(topic, []).include?(partition)
596
+ end
597
+ end
598
+ end
599
+
600
+ def scan_for_subscribing
601
+ @subscribed_topics.each do |topic_or_regex, config|
602
+ default_offset = config.fetch(:default_offset)
603
+ start_from_beginning = config.fetch(:start_from_beginning)
604
+ max_bytes_per_partition = config.fetch(:max_bytes_per_partition)
605
+ if topic_or_regex.is_a?(Regexp)
606
+ subscribe_to_regex(topic_or_regex, default_offset, start_from_beginning, max_bytes_per_partition)
607
+ else
608
+ subscribe_to_topic(topic_or_regex, default_offset, start_from_beginning, max_bytes_per_partition)
609
+ end
610
+ end
611
+ end
612
+
613
+ def subscribe_to_regex(topic_regex, default_offset, start_from_beginning, max_bytes_per_partition)
614
+ cluster_topics.select { |topic| topic =~ topic_regex }.each do |topic|
615
+ subscribe_to_topic(topic, default_offset, start_from_beginning, max_bytes_per_partition)
616
+ end
617
+ end
618
+
619
+ def subscribe_to_topic(topic, default_offset, start_from_beginning, max_bytes_per_partition)
620
+ return if @matched_topics.include?(topic)
621
+ @matched_topics.add(topic)
622
+ @join_group_for_new_topics = true
623
+
624
+ @group.subscribe(topic)
625
+ @offset_manager.set_default_offset(topic, default_offset)
626
+ @fetcher.subscribe(topic, max_bytes_per_partition: max_bytes_per_partition)
627
+ @cluster.mark_as_stale!
628
+ end
629
+
630
+ def cluster_topics
631
+ attempts = 0
632
+ begin
633
+ attempts += 1
634
+ @cluster.list_topics
635
+ rescue Kafka::ConnectionError
636
+ @cluster.mark_as_stale!
637
+ retry unless attempts > 1
638
+ raise
639
+ end
640
+ end
641
+ end
642
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kafka/protocol/member_assignment"
4
+
5
+ module Kafka
6
+ class ConsumerGroup
7
+
8
+ # A consumer group partition assignor
9
+ class Assignor
10
+ Partition = Struct.new(:topic, :partition_id)
11
+
12
+ # @param cluster [Kafka::Cluster]
13
+ # @param strategy [Object] an object that implements #protocol_type,
14
+ # #user_data, and #assign.
15
+ def initialize(cluster:, strategy:)
16
+ @cluster = cluster
17
+ @strategy = strategy
18
+ end
19
+
20
+ def protocol_name
21
+ @strategy.respond_to?(:protocol_name) ? @strategy.protocol_name : @strategy.class.to_s
22
+ end
23
+
24
+ def user_data
25
+ @strategy.user_data if @strategy.respond_to?(:user_data)
26
+ end
27
+
28
+ # Assign the topic partitions to the group members.
29
+ #
30
+ # @param members [Hash<String, Kafka::Protocol::JoinGroupResponse::Metadata>] a hash
31
+ # mapping member ids to metadata.
32
+ # @param topics [Array<String>] topics
33
+ # @return [Hash<String, Kafka::Protocol::MemberAssignment>] a hash mapping member
34
+ # ids to assignments.
35
+ def assign(members:, topics:)
36
+ topic_partitions = topics.flat_map do |topic|
37
+ begin
38
+ partition_ids = @cluster.partitions_for(topic).map(&:partition_id)
39
+ rescue UnknownTopicOrPartition
40
+ raise UnknownTopicOrPartition, "unknown topic #{topic}"
41
+ end
42
+ partition_ids.map {|partition_id| Partition.new(topic, partition_id) }
43
+ end
44
+
45
+ group_assignment = {}
46
+
47
+ members.each_key do |member_id|
48
+ group_assignment[member_id] = Protocol::MemberAssignment.new
49
+ end
50
+ @strategy.call(cluster: @cluster, members: members, partitions: topic_partitions).each do |member_id, partitions|
51
+ Array(partitions).each do |partition|
52
+ group_assignment[member_id].assign(partition.topic, [partition.partition_id])
53
+ end
54
+ end
55
+
56
+ group_assignment
57
+ rescue Kafka::LeaderNotAvailable
58
+ sleep 1
59
+ retry
60
+ end
61
+ end
62
+ end
63
+ end