ruby-kafka-custom 0.7.7.26

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 (105) hide show
  1. checksums.yaml +7 -0
  2. data/lib/kafka/async_producer.rb +279 -0
  3. data/lib/kafka/broker.rb +205 -0
  4. data/lib/kafka/broker_info.rb +16 -0
  5. data/lib/kafka/broker_pool.rb +41 -0
  6. data/lib/kafka/broker_uri.rb +43 -0
  7. data/lib/kafka/client.rb +754 -0
  8. data/lib/kafka/cluster.rb +455 -0
  9. data/lib/kafka/compression.rb +43 -0
  10. data/lib/kafka/compressor.rb +85 -0
  11. data/lib/kafka/connection.rb +220 -0
  12. data/lib/kafka/connection_builder.rb +33 -0
  13. data/lib/kafka/consumer.rb +592 -0
  14. data/lib/kafka/consumer_group.rb +208 -0
  15. data/lib/kafka/datadog.rb +413 -0
  16. data/lib/kafka/fetch_operation.rb +115 -0
  17. data/lib/kafka/fetched_batch.rb +54 -0
  18. data/lib/kafka/fetched_batch_generator.rb +117 -0
  19. data/lib/kafka/fetched_message.rb +47 -0
  20. data/lib/kafka/fetched_offset_resolver.rb +48 -0
  21. data/lib/kafka/fetcher.rb +221 -0
  22. data/lib/kafka/gzip_codec.rb +30 -0
  23. data/lib/kafka/heartbeat.rb +25 -0
  24. data/lib/kafka/instrumenter.rb +38 -0
  25. data/lib/kafka/lz4_codec.rb +23 -0
  26. data/lib/kafka/message_buffer.rb +87 -0
  27. data/lib/kafka/offset_manager.rb +248 -0
  28. data/lib/kafka/partitioner.rb +35 -0
  29. data/lib/kafka/pause.rb +92 -0
  30. data/lib/kafka/pending_message.rb +29 -0
  31. data/lib/kafka/pending_message_queue.rb +41 -0
  32. data/lib/kafka/produce_operation.rb +205 -0
  33. data/lib/kafka/producer.rb +504 -0
  34. data/lib/kafka/protocol.rb +217 -0
  35. data/lib/kafka/protocol/add_partitions_to_txn_request.rb +34 -0
  36. data/lib/kafka/protocol/add_partitions_to_txn_response.rb +47 -0
  37. data/lib/kafka/protocol/alter_configs_request.rb +44 -0
  38. data/lib/kafka/protocol/alter_configs_response.rb +49 -0
  39. data/lib/kafka/protocol/api_versions_request.rb +21 -0
  40. data/lib/kafka/protocol/api_versions_response.rb +53 -0
  41. data/lib/kafka/protocol/consumer_group_protocol.rb +19 -0
  42. data/lib/kafka/protocol/create_partitions_request.rb +42 -0
  43. data/lib/kafka/protocol/create_partitions_response.rb +28 -0
  44. data/lib/kafka/protocol/create_topics_request.rb +45 -0
  45. data/lib/kafka/protocol/create_topics_response.rb +26 -0
  46. data/lib/kafka/protocol/decoder.rb +175 -0
  47. data/lib/kafka/protocol/delete_topics_request.rb +33 -0
  48. data/lib/kafka/protocol/delete_topics_response.rb +26 -0
  49. data/lib/kafka/protocol/describe_configs_request.rb +35 -0
  50. data/lib/kafka/protocol/describe_configs_response.rb +73 -0
  51. data/lib/kafka/protocol/describe_groups_request.rb +27 -0
  52. data/lib/kafka/protocol/describe_groups_response.rb +73 -0
  53. data/lib/kafka/protocol/encoder.rb +184 -0
  54. data/lib/kafka/protocol/end_txn_request.rb +29 -0
  55. data/lib/kafka/protocol/end_txn_response.rb +19 -0
  56. data/lib/kafka/protocol/fetch_request.rb +70 -0
  57. data/lib/kafka/protocol/fetch_response.rb +136 -0
  58. data/lib/kafka/protocol/find_coordinator_request.rb +29 -0
  59. data/lib/kafka/protocol/find_coordinator_response.rb +29 -0
  60. data/lib/kafka/protocol/heartbeat_request.rb +27 -0
  61. data/lib/kafka/protocol/heartbeat_response.rb +17 -0
  62. data/lib/kafka/protocol/init_producer_id_request.rb +26 -0
  63. data/lib/kafka/protocol/init_producer_id_response.rb +27 -0
  64. data/lib/kafka/protocol/join_group_request.rb +41 -0
  65. data/lib/kafka/protocol/join_group_response.rb +33 -0
  66. data/lib/kafka/protocol/leave_group_request.rb +25 -0
  67. data/lib/kafka/protocol/leave_group_response.rb +17 -0
  68. data/lib/kafka/protocol/list_groups_request.rb +23 -0
  69. data/lib/kafka/protocol/list_groups_response.rb +35 -0
  70. data/lib/kafka/protocol/list_offset_request.rb +53 -0
  71. data/lib/kafka/protocol/list_offset_response.rb +89 -0
  72. data/lib/kafka/protocol/member_assignment.rb +42 -0
  73. data/lib/kafka/protocol/message.rb +172 -0
  74. data/lib/kafka/protocol/message_set.rb +55 -0
  75. data/lib/kafka/protocol/metadata_request.rb +31 -0
  76. data/lib/kafka/protocol/metadata_response.rb +185 -0
  77. data/lib/kafka/protocol/offset_commit_request.rb +47 -0
  78. data/lib/kafka/protocol/offset_commit_response.rb +29 -0
  79. data/lib/kafka/protocol/offset_fetch_request.rb +36 -0
  80. data/lib/kafka/protocol/offset_fetch_response.rb +56 -0
  81. data/lib/kafka/protocol/produce_request.rb +92 -0
  82. data/lib/kafka/protocol/produce_response.rb +63 -0
  83. data/lib/kafka/protocol/record.rb +88 -0
  84. data/lib/kafka/protocol/record_batch.rb +222 -0
  85. data/lib/kafka/protocol/request_message.rb +26 -0
  86. data/lib/kafka/protocol/sasl_handshake_request.rb +33 -0
  87. data/lib/kafka/protocol/sasl_handshake_response.rb +28 -0
  88. data/lib/kafka/protocol/sync_group_request.rb +33 -0
  89. data/lib/kafka/protocol/sync_group_response.rb +23 -0
  90. data/lib/kafka/round_robin_assignment_strategy.rb +54 -0
  91. data/lib/kafka/sasl/gssapi.rb +76 -0
  92. data/lib/kafka/sasl/oauth.rb +64 -0
  93. data/lib/kafka/sasl/plain.rb +39 -0
  94. data/lib/kafka/sasl/scram.rb +177 -0
  95. data/lib/kafka/sasl_authenticator.rb +61 -0
  96. data/lib/kafka/snappy_codec.rb +25 -0
  97. data/lib/kafka/socket_with_timeout.rb +96 -0
  98. data/lib/kafka/ssl_context.rb +66 -0
  99. data/lib/kafka/ssl_socket_with_timeout.rb +187 -0
  100. data/lib/kafka/statsd.rb +296 -0
  101. data/lib/kafka/tagged_logger.rb +72 -0
  102. data/lib/kafka/transaction_manager.rb +261 -0
  103. data/lib/kafka/transaction_state_machine.rb +72 -0
  104. data/lib/kafka/version.rb +5 -0
  105. metadata +461 -0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kafka/snappy_codec"
4
+ require "kafka/gzip_codec"
5
+ require "kafka/lz4_codec"
6
+
7
+ module Kafka
8
+ module Compression
9
+ CODEC_NAMES = {
10
+ 1 => :gzip,
11
+ 2 => :snappy,
12
+ 3 => :lz4,
13
+ }.freeze
14
+
15
+ CODECS = {
16
+ :gzip => GzipCodec.new,
17
+ :snappy => SnappyCodec.new,
18
+ :lz4 => LZ4Codec.new,
19
+ }.freeze
20
+
21
+ def self.codecs
22
+ CODECS.keys
23
+ end
24
+
25
+ def self.find_codec(name)
26
+ codec = CODECS.fetch(name) do
27
+ raise "Unknown compression codec #{name}"
28
+ end
29
+
30
+ codec.load
31
+
32
+ codec
33
+ end
34
+
35
+ def self.find_codec_by_id(codec_id)
36
+ codec_name = CODEC_NAMES.fetch(codec_id) do
37
+ raise "Unknown codec id #{codec_id}"
38
+ end
39
+
40
+ find_codec(codec_name)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kafka/compression"
4
+
5
+ module Kafka
6
+
7
+ # Compresses message sets using a specified codec.
8
+ #
9
+ # A message set is only compressed if its size meets the defined threshold.
10
+ #
11
+ # ## Instrumentation
12
+ #
13
+ # Whenever a message set is compressed, the notification
14
+ # `compress.compressor.kafka` will be emitted with the following payload:
15
+ #
16
+ # * `message_count` – the number of messages in the message set.
17
+ # * `uncompressed_bytesize` – the byte size of the original data.
18
+ # * `compressed_bytesize` – the byte size of the compressed data.
19
+ #
20
+ class Compressor
21
+
22
+ # @param codec_name [Symbol, nil]
23
+ # @param threshold [Integer] the minimum number of messages in a message set
24
+ # that will trigger compression.
25
+ def initialize(codec_name: nil, threshold: 1, instrumenter:)
26
+ # Codec may be nil, in which case we won't compress.
27
+ @codec = codec_name && Compression.find_codec(codec_name)
28
+
29
+ @threshold = threshold
30
+ @instrumenter = instrumenter
31
+ end
32
+
33
+ # @param record_batch [Protocol::RecordBatch]
34
+ # @param offset [Integer] used to simulate broker behaviour in tests
35
+ # @return [Protocol::RecordBatch]
36
+ def compress(record_batch, offset: -1)
37
+ if record_batch.is_a?(Protocol::RecordBatch)
38
+ compress_record_batch(record_batch)
39
+ else
40
+ # Deprecated message set format
41
+ compress_message_set(record_batch, offset)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def compress_message_set(message_set, offset)
48
+ return message_set if @codec.nil? || message_set.size < @threshold
49
+
50
+ data = Protocol::Encoder.encode_with(message_set)
51
+ compressed_data = @codec.compress(data)
52
+
53
+ @instrumenter.instrument("compress.compressor") do |notification|
54
+ notification[:message_count] = message_set.size
55
+ notification[:uncompressed_bytesize] = data.bytesize
56
+ notification[:compressed_bytesize] = compressed_data.bytesize
57
+ end
58
+
59
+ wrapper_message = Protocol::Message.new(
60
+ value: compressed_data,
61
+ codec_id: @codec.codec_id,
62
+ offset: offset
63
+ )
64
+
65
+ Protocol::MessageSet.new(messages: [wrapper_message])
66
+ end
67
+
68
+ def compress_record_batch(record_batch)
69
+ if @codec.nil? || record_batch.size < @threshold
70
+ record_batch.codec_id = 0
71
+ return Protocol::Encoder.encode_with(record_batch)
72
+ end
73
+
74
+ record_batch.codec_id = @codec.codec_id
75
+ data = Protocol::Encoder.encode_with(record_batch)
76
+
77
+ @instrumenter.instrument("compress.compressor") do |notification|
78
+ notification[:message_count] = record_batch.size
79
+ notification[:compressed_bytesize] = data.bytesize
80
+ end
81
+
82
+ data
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "kafka/socket_with_timeout"
5
+ require "kafka/ssl_socket_with_timeout"
6
+ require "kafka/protocol/request_message"
7
+ require "kafka/protocol/encoder"
8
+ require "kafka/protocol/decoder"
9
+
10
+ module Kafka
11
+
12
+ # A connection to a single Kafka broker.
13
+ #
14
+ # Usually you'll need a separate connection to each broker in a cluster, since most
15
+ # requests must be directed specifically to the broker that is currently leader for
16
+ # the set of topic partitions you want to produce to or consume from.
17
+ #
18
+ # ## Instrumentation
19
+ #
20
+ # Connections emit a `request.connection.kafka` notification on each request. The following
21
+ # keys will be found in the payload:
22
+ #
23
+ # * `:api` — the name of the API being invoked.
24
+ # * `:request_size` — the number of bytes in the request.
25
+ # * `:response_size` — the number of bytes in the response.
26
+ #
27
+ # The notification also includes the duration of the request.
28
+ #
29
+ class Connection
30
+ SOCKET_TIMEOUT = 10
31
+ CONNECT_TIMEOUT = 10
32
+
33
+ # Time after which an idle connection will be reopened.
34
+ IDLE_TIMEOUT = 60 * 5
35
+
36
+ attr_reader :encoder
37
+ attr_reader :decoder
38
+
39
+ # Opens a connection to a Kafka broker.
40
+ #
41
+ # @param host [String] the hostname of the broker.
42
+ # @param port [Integer] the port of the broker.
43
+ # @param client_id [String] the client id is a user-specified string sent in each
44
+ # request to help trace calls and should logically identify the application
45
+ # making the request.
46
+ # @param logger [Logger] the logger used to log trace messages.
47
+ # @param connect_timeout [Integer] the socket timeout for connecting to the broker.
48
+ # Default is 10 seconds.
49
+ # @param socket_timeout [Integer] the socket timeout for reading and writing to the
50
+ # broker. Default is 10 seconds.
51
+ #
52
+ # @return [Connection] a new connection.
53
+ def initialize(host:, port:, client_id:, logger:, instrumenter:, connect_timeout: nil, socket_timeout: nil, ssl_context: nil)
54
+ @host, @port, @client_id = host, port, client_id
55
+ @logger = TaggedLogger.new(logger)
56
+ @instrumenter = instrumenter
57
+
58
+ @connect_timeout = connect_timeout || CONNECT_TIMEOUT
59
+ @socket_timeout = socket_timeout || SOCKET_TIMEOUT
60
+ @ssl_context = ssl_context
61
+ end
62
+
63
+ def to_s
64
+ "#{@host}:#{@port}"
65
+ end
66
+
67
+ def open?
68
+ !@socket.nil? && !@socket.closed?
69
+ end
70
+
71
+ def close
72
+ @logger.debug "Closing socket to #{to_s}"
73
+
74
+ @socket.close if @socket
75
+ end
76
+
77
+ # Sends a request over the connection.
78
+ #
79
+ # @param request [#encode, #response_class] the request that should be
80
+ # encoded and written.
81
+ #
82
+ # @return [Object] the response.
83
+ def send_request(request)
84
+ api_name = Protocol.api_name(request.api_key)
85
+
86
+ # Default notification payload.
87
+ notification = {
88
+ broker_host: @host,
89
+ api: api_name,
90
+ request_size: 0,
91
+ response_size: 0,
92
+ }
93
+
94
+ raise IdleConnection if idle?
95
+
96
+ @logger.push_tags(api_name)
97
+ @instrumenter.instrument("request.connection", notification) do
98
+ open unless open?
99
+
100
+ @correlation_id += 1
101
+
102
+ @logger.debug "Sending #{api_name} API request #{@correlation_id} to #{to_s}"
103
+
104
+ write_request(request, notification)
105
+
106
+ response_class = request.response_class
107
+ response = wait_for_response(response_class, notification) unless response_class.nil?
108
+
109
+ @last_request = Time.now
110
+
111
+ response
112
+ end
113
+ rescue SystemCallError, EOFError, IOError => e
114
+ close
115
+
116
+ raise ConnectionError, "Connection error #{e.class}: #{e}"
117
+ ensure
118
+ @logger.pop_tags
119
+ end
120
+
121
+ private
122
+
123
+ def open
124
+ @logger.debug "Opening connection to #{@host}:#{@port} with client id #{@client_id}..."
125
+
126
+ if @ssl_context
127
+ @socket = SSLSocketWithTimeout.new(@host, @port, connect_timeout: @connect_timeout, timeout: @socket_timeout, ssl_context: @ssl_context)
128
+ else
129
+ @socket = SocketWithTimeout.new(@host, @port, connect_timeout: @connect_timeout, timeout: @socket_timeout)
130
+ end
131
+
132
+ @encoder = Kafka::Protocol::Encoder.new(@socket)
133
+ @decoder = Kafka::Protocol::Decoder.new(@socket)
134
+
135
+ # Correlation id is initialized to zero and bumped for each request.
136
+ @correlation_id = 0
137
+
138
+ @last_request = nil
139
+ rescue Errno::ETIMEDOUT => e
140
+ @logger.error "Timed out while trying to connect to #{self}: #{e}"
141
+ raise ConnectionError, e
142
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
143
+ @logger.error "Failed to connect to #{self}: #{e}"
144
+ raise ConnectionError, e
145
+ end
146
+
147
+ def idle?
148
+ @last_request && @last_request < Time.now - IDLE_TIMEOUT
149
+ end
150
+
151
+ # Writes a request over the connection.
152
+ #
153
+ # @param request [#encode] the request that should be encoded and written.
154
+ #
155
+ # @return [nil]
156
+ def write_request(request, notification)
157
+ message = Kafka::Protocol::RequestMessage.new(
158
+ api_key: request.api_key,
159
+ api_version: request.respond_to?(:api_version) ? request.api_version : 0,
160
+ correlation_id: @correlation_id,
161
+ client_id: @client_id,
162
+ request: request,
163
+ )
164
+
165
+ data = Kafka::Protocol::Encoder.encode_with(message)
166
+ notification[:request_size] = data.bytesize
167
+
168
+ @encoder.write_bytes(data)
169
+
170
+ nil
171
+ rescue Errno::ETIMEDOUT
172
+ @logger.error "Timed out while writing request #{@correlation_id}"
173
+ raise
174
+ end
175
+
176
+ # Reads a response from the connection.
177
+ #
178
+ # @param response_class [#decode] an object that can decode the response from
179
+ # a given Decoder.
180
+ #
181
+ # @return [nil]
182
+ def read_response(response_class, notification)
183
+ @logger.debug "Waiting for response #{@correlation_id} from #{to_s}"
184
+
185
+ data = @decoder.bytes
186
+ notification[:response_size] = data.bytesize
187
+
188
+ buffer = StringIO.new(data)
189
+ response_decoder = Kafka::Protocol::Decoder.new(buffer)
190
+
191
+ correlation_id = response_decoder.int32
192
+ response = response_class.decode(response_decoder)
193
+
194
+ @logger.debug "Received response #{correlation_id} from #{to_s}"
195
+
196
+ return correlation_id, response
197
+ rescue Errno::ETIMEDOUT
198
+ @logger.error "Timed out while waiting for response #{@correlation_id}"
199
+ raise
200
+ end
201
+
202
+ def wait_for_response(response_class, notification)
203
+ loop do
204
+ correlation_id, response = read_response(response_class, notification)
205
+
206
+ # There may have been a previous request that timed out before the client
207
+ # was able to read the response. In that case, the response will still be
208
+ # sitting in the socket waiting to be read. If the response we just read
209
+ # was to a previous request, we can safely skip it.
210
+ if correlation_id < @correlation_id
211
+ @logger.error "Received out-of-order response id #{correlation_id}, was expecting #{@correlation_id}"
212
+ elsif correlation_id > @correlation_id
213
+ raise Kafka::Error, "Correlation id mismatch: expected #{@correlation_id} but got #{correlation_id}"
214
+ else
215
+ return response
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kafka
4
+ class ConnectionBuilder
5
+ def initialize(client_id:, logger:, instrumenter:, connect_timeout:, socket_timeout:, ssl_context:, sasl_authenticator:)
6
+ @client_id = client_id
7
+ @logger = TaggedLogger.new(logger)
8
+ @instrumenter = instrumenter
9
+ @connect_timeout = connect_timeout
10
+ @socket_timeout = socket_timeout
11
+ @ssl_context = ssl_context
12
+ @sasl_authenticator = sasl_authenticator
13
+ end
14
+
15
+ def build_connection(host, port)
16
+ connection = Connection.new(
17
+ host: host,
18
+ port: port,
19
+ client_id: @client_id,
20
+ connect_timeout: @connect_timeout,
21
+ socket_timeout: @socket_timeout,
22
+ logger: @logger,
23
+ instrumenter: @instrumenter,
24
+ ssl_context: @ssl_context,
25
+ )
26
+
27
+ @sasl_authenticator.authenticate!(connection)
28
+
29
+ connection
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,592 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kafka/consumer_group"
4
+ require "kafka/offset_manager"
5
+ require "kafka/fetcher"
6
+ require "kafka/pause"
7
+
8
+ module Kafka
9
+
10
+ # A client that consumes messages from a Kafka cluster in coordination with
11
+ # other clients.
12
+ #
13
+ # A Consumer subscribes to one or more Kafka topics; all consumers with the
14
+ # same *group id* then agree on who should read from the individual topic
15
+ # partitions. When group members join or leave, the group synchronizes,
16
+ # making sure that all partitions are assigned to a single member, and that
17
+ # all members have some partitions to read from.
18
+ #
19
+ # ## Example
20
+ #
21
+ # A simple producer that simply writes the messages it consumes to the
22
+ # console.
23
+ #
24
+ # require "kafka"
25
+ #
26
+ # kafka = Kafka.new(["kafka1:9092", "kafka2:9092"])
27
+ #
28
+ # # Create a new Consumer instance in the group `my-group`:
29
+ # consumer = kafka.consumer(group_id: "my-group")
30
+ #
31
+ # # Subscribe to a Kafka topic:
32
+ # consumer.subscribe("messages")
33
+ #
34
+ # # Loop forever, reading in messages from all topics that have been
35
+ # # subscribed to.
36
+ # consumer.each_message do |message|
37
+ # puts message.topic
38
+ # puts message.partition
39
+ # puts message.key
40
+ # puts message.headers
41
+ # puts message.value
42
+ # puts message.offset
43
+ # end
44
+ #
45
+ class Consumer
46
+
47
+ def initialize(cluster:, logger:, instrumenter:, group:, fetcher:, offset_manager:, session_timeout:, heartbeat:)
48
+ @cluster = cluster
49
+ @logger = TaggedLogger.new(logger)
50
+ @instrumenter = instrumenter
51
+ @group = group
52
+ @offset_manager = offset_manager
53
+ @session_timeout = session_timeout
54
+ @fetcher = fetcher
55
+ @heartbeat = heartbeat
56
+
57
+ @pauses = Hash.new {|h, k|
58
+ h[k] = Hash.new {|h2, k2|
59
+ h2[k2] = Pause.new
60
+ }
61
+ }
62
+
63
+ # Whether or not the consumer is currently consuming messages.
64
+ @running = false
65
+
66
+ # Hash containing offsets for each topic and partition that has the
67
+ # automatically_mark_as_processed feature disabled. Offset manager is only active
68
+ # when everything is suppose to happen automatically. Otherwise we need to keep track of the
69
+ # offset manually in memory for all the time
70
+ # The key structure for this equals an array with topic and partition [topic, partition]
71
+ # The value is equal to the offset of the last message we've received
72
+ # @note It won't be updated in case user marks message as processed, because for the case
73
+ # when user commits message other than last in a batch, this would make ruby-kafka refetch
74
+ # some already consumed messages
75
+ @current_offsets = Hash.new { |h, k| h[k] = {} }
76
+ end
77
+
78
+ # Subscribes the consumer to a topic.
79
+ #
80
+ # Typically you either want to start reading messages from the very
81
+ # beginning of the topic's partitions or you simply want to wait for new
82
+ # messages to be written. In the former case, set `start_from_beginning`
83
+ # to true (the default); in the latter, set it to false.
84
+ #
85
+ # @param topic_or_regex [String, Regexp] subscribe to single topic with a string
86
+ # or multiple topics matching a regex.
87
+ # @param default_offset [Symbol] whether to start from the beginning or the
88
+ # end of the topic's partitions. Deprecated.
89
+ # @param start_from_beginning [Boolean] whether to start from the beginning
90
+ # of the topic or just subscribe to new messages being produced. This
91
+ # only applies when first consuming a topic partition – once the consumer
92
+ # has checkpointed its progress, it will always resume from the last
93
+ # checkpoint.
94
+ # @param max_bytes_per_partition [Integer] the maximum amount of data fetched
95
+ # from a single partition at a time.
96
+ # @return [nil]
97
+ def subscribe(topic_or_regex, default_offset: nil, start_from_beginning: true, max_bytes_per_partition: 1048576)
98
+ default_offset ||= start_from_beginning ? :earliest : :latest
99
+
100
+ if topic_or_regex.is_a?(Regexp)
101
+ cluster_topics.select { |topic| topic =~ topic_or_regex }.each do |topic|
102
+ subscribe_to_topic(topic, default_offset, start_from_beginning, max_bytes_per_partition)
103
+ end
104
+ else
105
+ subscribe_to_topic(topic_or_regex, default_offset, start_from_beginning, max_bytes_per_partition)
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ # Stop the consumer.
112
+ #
113
+ # The consumer will finish any in-progress work and shut down.
114
+ #
115
+ # @return [nil]
116
+ def stop
117
+ @running = false
118
+ @fetcher.stop
119
+ @cluster.disconnect
120
+ end
121
+
122
+ # Pause processing of a specific topic partition.
123
+ #
124
+ # When a specific message causes the processor code to fail, it can be a good
125
+ # idea to simply pause the partition until the error can be resolved, allowing
126
+ # the rest of the partitions to continue being processed.
127
+ #
128
+ # If the `timeout` argument is passed, the partition will automatically be
129
+ # resumed when the timeout expires. If `exponential_backoff` is enabled, each
130
+ # subsequent pause will cause the timeout to double until a message from the
131
+ # partition has been successfully processed.
132
+ #
133
+ # @param topic [String]
134
+ # @param partition [Integer]
135
+ # @param timeout [nil, Integer] the number of seconds to pause the partition for,
136
+ # or `nil` if the partition should not be automatically resumed.
137
+ # @param max_timeout [nil, Integer] the maximum number of seconds to pause for,
138
+ # or `nil` if no maximum should be enforced.
139
+ # @param exponential_backoff [Boolean] whether to enable exponential backoff.
140
+ # @return [nil]
141
+ def pause(topic, partition, timeout: nil, max_timeout: nil, exponential_backoff: false)
142
+ if max_timeout && !exponential_backoff
143
+ raise ArgumentError, "`max_timeout` only makes sense when `exponential_backoff` is enabled"
144
+ end
145
+
146
+ pause_for(topic, partition).pause!(
147
+ timeout: timeout,
148
+ max_timeout: max_timeout,
149
+ exponential_backoff: exponential_backoff,
150
+ )
151
+ end
152
+
153
+ # Resume processing of a topic partition.
154
+ #
155
+ # @see #pause
156
+ # @param topic [String]
157
+ # @param partition [Integer]
158
+ # @return [nil]
159
+ def resume(topic, partition)
160
+ pause_for(topic, partition).resume!
161
+
162
+ # During re-balancing we might have lost the paused partition. Check if partition is still in group before seek.
163
+ seek_to_next(topic, partition) if @group.assigned_to?(topic, partition)
164
+ end
165
+
166
+ # Whether the topic partition is currently paused.
167
+ #
168
+ # @see #pause
169
+ # @param topic [String]
170
+ # @param partition [Integer]
171
+ # @return [Boolean] true if the partition is paused, false otherwise.
172
+ def paused?(topic, partition)
173
+ pause = pause_for(topic, partition)
174
+ pause.paused? && !pause.expired?
175
+ end
176
+
177
+ # Fetches and enumerates the messages in the topics that the consumer group
178
+ # subscribes to.
179
+ #
180
+ # Each message is yielded to the provided block. If the block returns
181
+ # without raising an exception, the message will be considered successfully
182
+ # processed. At regular intervals the offset of the most recent successfully
183
+ # processed message in each partition will be committed to the Kafka
184
+ # offset store. If the consumer crashes or leaves the group, the group member
185
+ # that is tasked with taking over processing of these partitions will resume
186
+ # at the last committed offsets.
187
+ #
188
+ # @param min_bytes [Integer] the minimum number of bytes to read before
189
+ # returning messages from each broker; if `max_wait_time` is reached, this
190
+ # is ignored.
191
+ # @param max_bytes [Integer] the maximum number of bytes to read before
192
+ # returning messages from each broker.
193
+ # @param max_wait_time [Integer, Float] the maximum duration of time to wait before
194
+ # returning messages from each broker, in seconds.
195
+ # @param automatically_mark_as_processed [Boolean] whether to automatically
196
+ # mark a message as successfully processed when the block returns
197
+ # without an exception. Once marked successful, the offsets of processed
198
+ # messages can be committed to Kafka.
199
+ # @yieldparam message [Kafka::FetchedMessage] a message fetched from Kafka.
200
+ # @raise [Kafka::ProcessingError] if there was an error processing a message.
201
+ # The original exception will be returned by calling `#cause` on the
202
+ # {Kafka::ProcessingError} instance.
203
+ # @return [nil]
204
+ def each_message(min_bytes: 1, max_bytes: 10485760, max_wait_time: 1, automatically_mark_as_processed: true)
205
+ @fetcher.configure(
206
+ min_bytes: min_bytes,
207
+ max_bytes: max_bytes,
208
+ max_wait_time: max_wait_time,
209
+ )
210
+
211
+ consumer_loop do
212
+ batches = fetch_batches
213
+
214
+ batches.each do |batch|
215
+ batch.messages.each do |message|
216
+ notification = {
217
+ topic: message.topic,
218
+ partition: message.partition,
219
+ offset: message.offset,
220
+ offset_lag: batch.highwater_mark_offset - message.offset - 1,
221
+ create_time: message.create_time,
222
+ key: message.key,
223
+ value: message.value,
224
+ headers: message.headers
225
+ }
226
+
227
+ # Instrument an event immediately so that subscribers don't have to wait until
228
+ # the block is completed.
229
+ @instrumenter.instrument("start_process_message.consumer", notification)
230
+
231
+ @instrumenter.instrument("process_message.consumer", notification) do
232
+ begin
233
+ yield message unless message.is_control_record
234
+ @current_offsets[message.topic][message.partition] = message.offset
235
+ rescue => e
236
+ location = "#{message.topic}/#{message.partition} at offset #{message.offset}"
237
+ backtrace = e.backtrace.join("\n")
238
+ @logger.error "Exception raised when processing #{location} -- #{e.class}: #{e}\n#{backtrace}"
239
+
240
+ raise ProcessingError.new(message.topic, message.partition, message.offset)
241
+ end
242
+ end
243
+
244
+ mark_message_as_processed(message) if automatically_mark_as_processed
245
+ @offset_manager.commit_offsets_if_necessary
246
+
247
+ trigger_heartbeat
248
+
249
+ return if shutting_down?
250
+ end
251
+
252
+ # We've successfully processed a batch from the partition, so we can clear
253
+ # the pause.
254
+ pause_for(batch.topic, batch.partition).reset!
255
+ end
256
+
257
+ # We may not have received any messages, but it's still a good idea to
258
+ # commit offsets if we've processed messages in the last set of batches.
259
+ # This also ensures the offsets are retained if we haven't read any messages
260
+ # since the offset retention period has elapsed.
261
+ @offset_manager.commit_offsets_if_necessary
262
+ end
263
+ end
264
+
265
+ # Fetches and enumerates the messages in the topics that the consumer group
266
+ # subscribes to.
267
+ #
268
+ # Each batch of messages is yielded to the provided block. If the block returns
269
+ # without raising an exception, the batch will be considered successfully
270
+ # processed. At regular intervals the offset of the most recent successfully
271
+ # processed message batch in each partition will be committed to the Kafka
272
+ # offset store. If the consumer crashes or leaves the group, the group member
273
+ # that is tasked with taking over processing of these partitions will resume
274
+ # at the last committed offsets.
275
+ #
276
+ # @param min_bytes [Integer] the minimum number of bytes to read before
277
+ # returning messages from each broker; if `max_wait_time` is reached, this
278
+ # is ignored.
279
+ # @param max_bytes [Integer] the maximum number of bytes to read before
280
+ # returning messages from each broker.
281
+ # @param max_wait_time [Integer, Float] the maximum duration of time to wait before
282
+ # returning messages from each broker, in seconds.
283
+ # @param automatically_mark_as_processed [Boolean] whether to automatically
284
+ # mark a batch's messages as successfully processed when the block returns
285
+ # without an exception. Once marked successful, the offsets of processed
286
+ # messages can be committed to Kafka.
287
+ # @yieldparam batch [Kafka::FetchedBatch] a message batch fetched from Kafka.
288
+ # @raise [Kafka::ProcessingError] if there was an error processing a batch.
289
+ # The original exception will be returned by calling `#cause` on the
290
+ # {Kafka::ProcessingError} instance.
291
+ # @return [nil]
292
+ def each_batch(min_bytes: 1, max_bytes: 10485760, max_wait_time: 1, automatically_mark_as_processed: true)
293
+ @fetcher.configure(
294
+ min_bytes: min_bytes,
295
+ max_bytes: max_bytes,
296
+ max_wait_time: max_wait_time,
297
+ )
298
+
299
+ consumer_loop do
300
+ batches = fetch_batches
301
+
302
+ batches.each do |batch|
303
+ unless batch.empty?
304
+ raw_messages = batch.messages
305
+ batch.messages = raw_messages.reject(&:is_control_record)
306
+
307
+ notification = {
308
+ topic: batch.topic,
309
+ partition: batch.partition,
310
+ last_offset: batch.last_offset,
311
+ offset_lag: batch.offset_lag,
312
+ highwater_mark_offset: batch.highwater_mark_offset,
313
+ message_count: batch.messages.count,
314
+ }
315
+
316
+ # Instrument an event immediately so that subscribers don't have to wait until
317
+ # the block is completed.
318
+ @instrumenter.instrument("start_process_batch.consumer", notification)
319
+
320
+ @instrumenter.instrument("process_batch.consumer", notification) do
321
+ begin
322
+ yield batch
323
+ @current_offsets[batch.topic][batch.partition] = batch.last_offset unless batch.unknown_last_offset?
324
+ rescue => e
325
+ offset_range = (batch.first_offset..batch.last_offset || batch.highwater_mark_offset)
326
+ location = "#{batch.topic}/#{batch.partition} in offset range #{offset_range}"
327
+ backtrace = e.backtrace.join("\n")
328
+
329
+ @logger.error "Exception raised when processing #{location} -- #{e.class}: #{e}\n#{backtrace}"
330
+
331
+ raise ProcessingError.new(batch.topic, batch.partition, offset_range)
332
+ ensure
333
+ batch.messages = raw_messages
334
+ end
335
+ end
336
+ mark_message_as_processed(batch.messages.last) if automatically_mark_as_processed
337
+
338
+ # We've successfully processed a batch from the partition, so we can clear
339
+ # the pause.
340
+ pause_for(batch.topic, batch.partition).reset!
341
+ end
342
+
343
+ @offset_manager.commit_offsets_if_necessary
344
+
345
+ trigger_heartbeat
346
+
347
+ return if shutting_down?
348
+ end
349
+
350
+ # We may not have received any messages, but it's still a good idea to
351
+ # commit offsets if we've processed messages in the last set of batches.
352
+ # This also ensures the offsets are retained if we haven't read any messages
353
+ # since the offset retention period has elapsed.
354
+ @offset_manager.commit_offsets_if_necessary
355
+ end
356
+ end
357
+
358
+ # Move the consumer's position in a topic partition to the specified offset.
359
+ #
360
+ # Note that this has to be done prior to calling {#each_message} or {#each_batch}
361
+ # and only has an effect if the consumer is assigned the partition. Typically,
362
+ # you will want to do this in every consumer group member in order to make sure
363
+ # that the member that's assigned the partition knows where to start.
364
+ #
365
+ # @param topic [String]
366
+ # @param partition [Integer]
367
+ # @param offset [Integer]
368
+ # @return [nil]
369
+ def seek(topic, partition, offset)
370
+ @offset_manager.seek_to(topic, partition, offset)
371
+ end
372
+
373
+ def commit_offsets
374
+ @offset_manager.commit_offsets
375
+ end
376
+
377
+ def mark_message_as_processed(message)
378
+ @offset_manager.mark_as_processed(message.topic, message.partition, message.offset)
379
+ end
380
+
381
+ def trigger_heartbeat
382
+ @heartbeat.trigger
383
+ end
384
+
385
+ def trigger_heartbeat!
386
+ @heartbeat.trigger!
387
+ end
388
+
389
+ # Aliases for the external API compatibility
390
+ alias send_heartbeat_if_necessary trigger_heartbeat
391
+ alias send_heartbeat trigger_heartbeat!
392
+
393
+ private
394
+
395
+ def consumer_loop
396
+ @running = true
397
+ @logger.push_tags(@group.to_s)
398
+
399
+ @fetcher.start
400
+
401
+ while running?
402
+ begin
403
+ @instrumenter.instrument("loop.consumer") do
404
+ yield
405
+ end
406
+ rescue HeartbeatError
407
+ make_final_offsets_commit!
408
+ join_group if running?
409
+ rescue OffsetCommitError
410
+ join_group if running?
411
+ rescue RebalanceInProgress
412
+ @logger.warn "Group rebalance in progress, re-joining..."
413
+ join_group if running?
414
+ rescue FetchError, NotLeaderForPartition, UnknownTopicOrPartition
415
+ @cluster.mark_as_stale!
416
+ rescue LeaderNotAvailable => e
417
+ @logger.error "Leader not available; waiting 1s before retrying"
418
+ @cluster.mark_as_stale!
419
+ sleep 1
420
+ rescue ConnectionError => e
421
+ @logger.error "Connection error #{e.class}: #{e.message}"
422
+ @cluster.mark_as_stale!
423
+ rescue SignalException => e
424
+ @logger.warn "Received signal #{e.message}, shutting down"
425
+ @running = false
426
+ end
427
+ end
428
+ ensure
429
+ @fetcher.stop
430
+
431
+ # In order to quickly have the consumer group re-balance itself, it's
432
+ # important that members explicitly tell Kafka when they're leaving.
433
+ make_final_offsets_commit!
434
+ @group.leave rescue nil
435
+ @running = false
436
+ @logger.pop_tags
437
+ end
438
+
439
+ def make_final_offsets_commit!(attempts = 3)
440
+ @offset_manager.commit_offsets
441
+ rescue ConnectionError, OffsetCommitError, EOFError
442
+ # It's important to make sure final offsets commit is done
443
+ # As otherwise messages that have been processed after last auto-commit
444
+ # will be processed again and that may be huge amount of messages
445
+ return if attempts.zero?
446
+
447
+ @logger.error "Retrying to make final offsets commit (#{attempts} attempts left)"
448
+ sleep(0.1)
449
+ make_final_offsets_commit!(attempts - 1)
450
+ rescue Kafka::Error => e
451
+ @logger.error "Encountered error while shutting down; #{e.class}: #{e.message}"
452
+ end
453
+
454
+ def join_group
455
+ old_generation_id = @group.generation_id
456
+
457
+ @group.join
458
+
459
+ if old_generation_id && @group.generation_id != old_generation_id + 1
460
+ # We've been out of the group for at least an entire generation, no
461
+ # sense in trying to hold on to offset data
462
+ clear_current_offsets
463
+ @offset_manager.clear_offsets
464
+ else
465
+ # After rejoining the group we may have been assigned a new set of
466
+ # partitions. Keeping the old offset commits around forever would risk
467
+ # having the consumer go back and reprocess messages if it's assigned
468
+ # a partition it used to be assigned to way back. For that reason, we
469
+ # only keep commits for the partitions that we're still assigned.
470
+ clear_current_offsets(excluding: @group.assigned_partitions)
471
+ @offset_manager.clear_offsets_excluding(@group.assigned_partitions)
472
+ end
473
+
474
+ @fetcher.reset
475
+
476
+ @group.assigned_partitions.each do |topic, partitions|
477
+ partitions.each do |partition|
478
+ if paused?(topic, partition)
479
+ @logger.warn "Not fetching from #{topic}/#{partition} due to pause"
480
+ else
481
+ seek_to_next(topic, partition)
482
+ end
483
+ end
484
+ end
485
+ end
486
+
487
+ def seek_to_next(topic, partition)
488
+ # When automatic marking is off, the first poll needs to be based on the last committed
489
+ # offset from Kafka, that's why we fallback in case of nil (it may not be 0)
490
+ if @current_offsets[topic].key?(partition)
491
+ offset = @current_offsets[topic][partition] + 1
492
+ else
493
+ offset = @offset_manager.next_offset_for(topic, partition)
494
+ end
495
+
496
+ @fetcher.seek(topic, partition, offset)
497
+ end
498
+
499
+ def resume_paused_partitions!
500
+ @pauses.each do |topic, partitions|
501
+ partitions.each do |partition, pause|
502
+ @instrumenter.instrument("pause_status.consumer", {
503
+ topic: topic,
504
+ partition: partition,
505
+ duration: pause.pause_duration,
506
+ })
507
+
508
+ if pause.paused? && pause.expired?
509
+ @logger.info "Automatically resuming partition #{topic}/#{partition}, pause timeout expired"
510
+ resume(topic, partition)
511
+ end
512
+ end
513
+ end
514
+ end
515
+
516
+ def fetch_batches
517
+ # Return early if the consumer has been stopped.
518
+ return [] if shutting_down?
519
+
520
+ join_group unless @group.member?
521
+
522
+ trigger_heartbeat
523
+
524
+ resume_paused_partitions!
525
+
526
+ if !@fetcher.data?
527
+ @logger.debug "No batches to process"
528
+ sleep 2
529
+ []
530
+ else
531
+ tag, message = @fetcher.poll
532
+
533
+ case tag
534
+ when :batches
535
+ # make sure any old batches, fetched prior to the completion of a consumer group sync,
536
+ # are only processed if the batches are from brokers for which this broker is still responsible.
537
+ message.select { |batch| @group.assigned_to?(batch.topic, batch.partition) }
538
+ when :exception
539
+ raise message
540
+ end
541
+ end
542
+ rescue OffsetOutOfRange => e
543
+ @logger.error "Invalid offset #{e.offset} for #{e.topic}/#{e.partition}, resetting to default offset"
544
+
545
+ @offset_manager.seek_to_default(e.topic, e.partition)
546
+
547
+ retry
548
+ rescue ConnectionError => e
549
+ @logger.error "Connection error while fetching messages: #{e}"
550
+
551
+ raise FetchError, e
552
+ end
553
+
554
+ def pause_for(topic, partition)
555
+ @pauses[topic][partition]
556
+ end
557
+
558
+ def running?
559
+ @running
560
+ end
561
+
562
+ def shutting_down?
563
+ !running?
564
+ end
565
+
566
+ def clear_current_offsets(excluding: {})
567
+ @current_offsets.each do |topic, partitions|
568
+ partitions.keep_if do |partition, _|
569
+ excluding.fetch(topic, []).include?(partition)
570
+ end
571
+ end
572
+ end
573
+
574
+ def subscribe_to_topic(topic, default_offset, start_from_beginning, max_bytes_per_partition)
575
+ @group.subscribe(topic)
576
+ @offset_manager.set_default_offset(topic, default_offset)
577
+ @fetcher.subscribe(topic, max_bytes_per_partition: max_bytes_per_partition)
578
+ end
579
+
580
+ def cluster_topics
581
+ attempts = 0
582
+ begin
583
+ attempts += 1
584
+ @cluster.list_topics
585
+ rescue Kafka::ConnectionError
586
+ @cluster.mark_as_stale!
587
+ retry unless attempts > 1
588
+ raise
589
+ end
590
+ end
591
+ end
592
+ end