ruby-kafka-temp-fork 0.0.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 (144) 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 +310 -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 +1342 -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.rb +373 -0
  28. data/lib/kafka/async_producer.rb +291 -0
  29. data/lib/kafka/broker.rb +217 -0
  30. data/lib/kafka/broker_info.rb +16 -0
  31. data/lib/kafka/broker_pool.rb +41 -0
  32. data/lib/kafka/broker_uri.rb +43 -0
  33. data/lib/kafka/client.rb +833 -0
  34. data/lib/kafka/cluster.rb +513 -0
  35. data/lib/kafka/compression.rb +45 -0
  36. data/lib/kafka/compressor.rb +86 -0
  37. data/lib/kafka/connection.rb +223 -0
  38. data/lib/kafka/connection_builder.rb +33 -0
  39. data/lib/kafka/consumer.rb +642 -0
  40. data/lib/kafka/consumer_group.rb +231 -0
  41. data/lib/kafka/consumer_group/assignor.rb +63 -0
  42. data/lib/kafka/crc32_hash.rb +15 -0
  43. data/lib/kafka/datadog.rb +420 -0
  44. data/lib/kafka/digest.rb +22 -0
  45. data/lib/kafka/fetch_operation.rb +115 -0
  46. data/lib/kafka/fetched_batch.rb +58 -0
  47. data/lib/kafka/fetched_batch_generator.rb +120 -0
  48. data/lib/kafka/fetched_message.rb +48 -0
  49. data/lib/kafka/fetched_offset_resolver.rb +48 -0
  50. data/lib/kafka/fetcher.rb +224 -0
  51. data/lib/kafka/gzip_codec.rb +34 -0
  52. data/lib/kafka/heartbeat.rb +25 -0
  53. data/lib/kafka/instrumenter.rb +38 -0
  54. data/lib/kafka/interceptors.rb +33 -0
  55. data/lib/kafka/lz4_codec.rb +27 -0
  56. data/lib/kafka/message_buffer.rb +87 -0
  57. data/lib/kafka/murmur2_hash.rb +17 -0
  58. data/lib/kafka/offset_manager.rb +259 -0
  59. data/lib/kafka/partitioner.rb +40 -0
  60. data/lib/kafka/pause.rb +92 -0
  61. data/lib/kafka/pending_message.rb +29 -0
  62. data/lib/kafka/pending_message_queue.rb +41 -0
  63. data/lib/kafka/produce_operation.rb +205 -0
  64. data/lib/kafka/producer.rb +528 -0
  65. data/lib/kafka/prometheus.rb +316 -0
  66. data/lib/kafka/protocol.rb +225 -0
  67. data/lib/kafka/protocol/add_offsets_to_txn_request.rb +29 -0
  68. data/lib/kafka/protocol/add_offsets_to_txn_response.rb +21 -0
  69. data/lib/kafka/protocol/add_partitions_to_txn_request.rb +34 -0
  70. data/lib/kafka/protocol/add_partitions_to_txn_response.rb +47 -0
  71. data/lib/kafka/protocol/alter_configs_request.rb +44 -0
  72. data/lib/kafka/protocol/alter_configs_response.rb +49 -0
  73. data/lib/kafka/protocol/api_versions_request.rb +21 -0
  74. data/lib/kafka/protocol/api_versions_response.rb +53 -0
  75. data/lib/kafka/protocol/consumer_group_protocol.rb +19 -0
  76. data/lib/kafka/protocol/create_partitions_request.rb +42 -0
  77. data/lib/kafka/protocol/create_partitions_response.rb +28 -0
  78. data/lib/kafka/protocol/create_topics_request.rb +45 -0
  79. data/lib/kafka/protocol/create_topics_response.rb +26 -0
  80. data/lib/kafka/protocol/decoder.rb +175 -0
  81. data/lib/kafka/protocol/delete_topics_request.rb +33 -0
  82. data/lib/kafka/protocol/delete_topics_response.rb +26 -0
  83. data/lib/kafka/protocol/describe_configs_request.rb +35 -0
  84. data/lib/kafka/protocol/describe_configs_response.rb +73 -0
  85. data/lib/kafka/protocol/describe_groups_request.rb +27 -0
  86. data/lib/kafka/protocol/describe_groups_response.rb +73 -0
  87. data/lib/kafka/protocol/encoder.rb +184 -0
  88. data/lib/kafka/protocol/end_txn_request.rb +29 -0
  89. data/lib/kafka/protocol/end_txn_response.rb +19 -0
  90. data/lib/kafka/protocol/fetch_request.rb +70 -0
  91. data/lib/kafka/protocol/fetch_response.rb +136 -0
  92. data/lib/kafka/protocol/find_coordinator_request.rb +29 -0
  93. data/lib/kafka/protocol/find_coordinator_response.rb +29 -0
  94. data/lib/kafka/protocol/heartbeat_request.rb +27 -0
  95. data/lib/kafka/protocol/heartbeat_response.rb +17 -0
  96. data/lib/kafka/protocol/init_producer_id_request.rb +26 -0
  97. data/lib/kafka/protocol/init_producer_id_response.rb +27 -0
  98. data/lib/kafka/protocol/join_group_request.rb +47 -0
  99. data/lib/kafka/protocol/join_group_response.rb +41 -0
  100. data/lib/kafka/protocol/leave_group_request.rb +25 -0
  101. data/lib/kafka/protocol/leave_group_response.rb +17 -0
  102. data/lib/kafka/protocol/list_groups_request.rb +23 -0
  103. data/lib/kafka/protocol/list_groups_response.rb +35 -0
  104. data/lib/kafka/protocol/list_offset_request.rb +53 -0
  105. data/lib/kafka/protocol/list_offset_response.rb +89 -0
  106. data/lib/kafka/protocol/member_assignment.rb +42 -0
  107. data/lib/kafka/protocol/message.rb +172 -0
  108. data/lib/kafka/protocol/message_set.rb +55 -0
  109. data/lib/kafka/protocol/metadata_request.rb +31 -0
  110. data/lib/kafka/protocol/metadata_response.rb +185 -0
  111. data/lib/kafka/protocol/offset_commit_request.rb +47 -0
  112. data/lib/kafka/protocol/offset_commit_response.rb +29 -0
  113. data/lib/kafka/protocol/offset_fetch_request.rb +38 -0
  114. data/lib/kafka/protocol/offset_fetch_response.rb +56 -0
  115. data/lib/kafka/protocol/produce_request.rb +94 -0
  116. data/lib/kafka/protocol/produce_response.rb +63 -0
  117. data/lib/kafka/protocol/record.rb +88 -0
  118. data/lib/kafka/protocol/record_batch.rb +223 -0
  119. data/lib/kafka/protocol/request_message.rb +26 -0
  120. data/lib/kafka/protocol/sasl_handshake_request.rb +33 -0
  121. data/lib/kafka/protocol/sasl_handshake_response.rb +28 -0
  122. data/lib/kafka/protocol/sync_group_request.rb +33 -0
  123. data/lib/kafka/protocol/sync_group_response.rb +26 -0
  124. data/lib/kafka/protocol/txn_offset_commit_request.rb +46 -0
  125. data/lib/kafka/protocol/txn_offset_commit_response.rb +47 -0
  126. data/lib/kafka/round_robin_assignment_strategy.rb +52 -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 +61 -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 +188 -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/ruby-kafka-temp-fork.rb +5 -0
  143. data/ruby-kafka-temp-fork.gemspec +54 -0
  144. metadata +520 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kafka/snappy_codec"
4
+ require "kafka/gzip_codec"
5
+ require "kafka/lz4_codec"
6
+ require "kafka/zstd_codec"
7
+
8
+ module Kafka
9
+ module Compression
10
+ CODECS_BY_NAME = {
11
+ :gzip => GzipCodec.new,
12
+ :snappy => SnappyCodec.new,
13
+ :lz4 => LZ4Codec.new,
14
+ :zstd => ZstdCodec.new,
15
+ }.freeze
16
+
17
+ CODECS_BY_ID = CODECS_BY_NAME.each_with_object({}) do |(_, codec), hash|
18
+ hash[codec.codec_id] = codec
19
+ end.freeze
20
+
21
+ def self.codecs
22
+ CODECS_BY_NAME.keys
23
+ end
24
+
25
+ def self.find_codec(name)
26
+ codec = CODECS_BY_NAME.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 = CODECS_BY_ID.fetch(codec_id) do
37
+ raise "Unknown codec id #{codec_id}"
38
+ end
39
+
40
+ codec.load
41
+
42
+ codec
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,86 @@
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
+ attr_reader :codec
22
+
23
+ # @param codec_name [Symbol, nil]
24
+ # @param threshold [Integer] the minimum number of messages in a message set
25
+ # that will trigger compression.
26
+ def initialize(codec_name: nil, threshold: 1, instrumenter:)
27
+ # Codec may be nil, in which case we won't compress.
28
+ @codec = codec_name && Compression.find_codec(codec_name)
29
+
30
+ @threshold = threshold
31
+ @instrumenter = instrumenter
32
+ end
33
+
34
+ # @param record_batch [Protocol::RecordBatch]
35
+ # @param offset [Integer] used to simulate broker behaviour in tests
36
+ # @return [Protocol::RecordBatch]
37
+ def compress(record_batch, offset: -1)
38
+ if record_batch.is_a?(Protocol::RecordBatch)
39
+ compress_record_batch(record_batch)
40
+ else
41
+ # Deprecated message set format
42
+ compress_message_set(record_batch, offset)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def compress_message_set(message_set, offset)
49
+ return message_set if @codec.nil? || message_set.size < @threshold
50
+
51
+ data = Protocol::Encoder.encode_with(message_set)
52
+ compressed_data = @codec.compress(data)
53
+
54
+ @instrumenter.instrument("compress.compressor") do |notification|
55
+ notification[:message_count] = message_set.size
56
+ notification[:uncompressed_bytesize] = data.bytesize
57
+ notification[:compressed_bytesize] = compressed_data.bytesize
58
+ end
59
+
60
+ wrapper_message = Protocol::Message.new(
61
+ value: compressed_data,
62
+ codec_id: @codec.codec_id,
63
+ offset: offset
64
+ )
65
+
66
+ Protocol::MessageSet.new(messages: [wrapper_message])
67
+ end
68
+
69
+ def compress_record_batch(record_batch)
70
+ if @codec.nil? || record_batch.size < @threshold
71
+ record_batch.codec_id = 0
72
+ return Protocol::Encoder.encode_with(record_batch)
73
+ end
74
+
75
+ record_batch.codec_id = @codec.codec_id
76
+ data = Protocol::Encoder.encode_with(record_batch)
77
+
78
+ @instrumenter.instrument("compress.compressor") do |notification|
79
+ notification[:message_count] = record_batch.size
80
+ notification[:compressed_bytesize] = data.bytesize
81
+ end
82
+
83
+ data
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,223 @@
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
+
62
+ @socket = nil
63
+ @last_request = nil
64
+ end
65
+
66
+ def to_s
67
+ "#{@host}:#{@port}"
68
+ end
69
+
70
+ def open?
71
+ !@socket.nil? && !@socket.closed?
72
+ end
73
+
74
+ def close
75
+ @logger.debug "Closing socket to #{to_s}"
76
+
77
+ @socket.close if @socket
78
+ end
79
+
80
+ # Sends a request over the connection.
81
+ #
82
+ # @param request [#encode, #response_class] the request that should be
83
+ # encoded and written.
84
+ #
85
+ # @return [Object] the response.
86
+ def send_request(request)
87
+ api_name = Protocol.api_name(request.api_key)
88
+
89
+ # Default notification payload.
90
+ notification = {
91
+ broker_host: @host,
92
+ api: api_name,
93
+ request_size: 0,
94
+ response_size: 0,
95
+ }
96
+
97
+ raise IdleConnection if idle?
98
+
99
+ @logger.push_tags(api_name)
100
+ @instrumenter.instrument("request.connection", notification) do
101
+ open unless open?
102
+
103
+ @correlation_id += 1
104
+
105
+ @logger.debug "Sending #{api_name} API request #{@correlation_id} to #{to_s}"
106
+
107
+ write_request(request, notification)
108
+
109
+ response_class = request.response_class
110
+ response = wait_for_response(response_class, notification) unless response_class.nil?
111
+
112
+ @last_request = Time.now
113
+
114
+ response
115
+ end
116
+ rescue SystemCallError, EOFError, IOError => e
117
+ close
118
+
119
+ raise ConnectionError, "Connection error #{e.class}: #{e}"
120
+ ensure
121
+ @logger.pop_tags
122
+ end
123
+
124
+ private
125
+
126
+ def open
127
+ @logger.debug "Opening connection to #{@host}:#{@port} with client id #{@client_id}..."
128
+
129
+ if @ssl_context
130
+ @socket = SSLSocketWithTimeout.new(@host, @port, connect_timeout: @connect_timeout, timeout: @socket_timeout, ssl_context: @ssl_context)
131
+ else
132
+ @socket = SocketWithTimeout.new(@host, @port, connect_timeout: @connect_timeout, timeout: @socket_timeout)
133
+ end
134
+
135
+ @encoder = Kafka::Protocol::Encoder.new(@socket)
136
+ @decoder = Kafka::Protocol::Decoder.new(@socket)
137
+
138
+ # Correlation id is initialized to zero and bumped for each request.
139
+ @correlation_id = 0
140
+
141
+ @last_request = nil
142
+ rescue Errno::ETIMEDOUT => e
143
+ @logger.error "Timed out while trying to connect to #{self}: #{e}"
144
+ raise ConnectionError, e
145
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
146
+ @logger.error "Failed to connect to #{self}: #{e}"
147
+ raise ConnectionError, e
148
+ end
149
+
150
+ def idle?
151
+ @last_request && @last_request < Time.now - IDLE_TIMEOUT
152
+ end
153
+
154
+ # Writes a request over the connection.
155
+ #
156
+ # @param request [#encode] the request that should be encoded and written.
157
+ #
158
+ # @return [nil]
159
+ def write_request(request, notification)
160
+ message = Kafka::Protocol::RequestMessage.new(
161
+ api_key: request.api_key,
162
+ api_version: request.respond_to?(:api_version) ? request.api_version : 0,
163
+ correlation_id: @correlation_id,
164
+ client_id: @client_id,
165
+ request: request,
166
+ )
167
+
168
+ data = Kafka::Protocol::Encoder.encode_with(message)
169
+ notification[:request_size] = data.bytesize
170
+
171
+ @encoder.write_bytes(data)
172
+
173
+ nil
174
+ rescue Errno::ETIMEDOUT
175
+ @logger.error "Timed out while writing request #{@correlation_id}"
176
+ raise
177
+ end
178
+
179
+ # Reads a response from the connection.
180
+ #
181
+ # @param response_class [#decode] an object that can decode the response from
182
+ # a given Decoder.
183
+ #
184
+ # @return [nil]
185
+ def read_response(response_class, notification)
186
+ @logger.debug "Waiting for response #{@correlation_id} from #{to_s}"
187
+
188
+ data = @decoder.bytes
189
+ notification[:response_size] = data.bytesize
190
+
191
+ buffer = StringIO.new(data)
192
+ response_decoder = Kafka::Protocol::Decoder.new(buffer)
193
+
194
+ correlation_id = response_decoder.int32
195
+ response = response_class.decode(response_decoder)
196
+
197
+ @logger.debug "Received response #{correlation_id} from #{to_s}"
198
+
199
+ return correlation_id, response
200
+ rescue Errno::ETIMEDOUT
201
+ @logger.error "Timed out while waiting for response #{@correlation_id}"
202
+ raise
203
+ end
204
+
205
+ def wait_for_response(response_class, notification)
206
+ loop do
207
+ correlation_id, response = read_response(response_class, notification)
208
+
209
+ # There may have been a previous request that timed out before the client
210
+ # was able to read the response. In that case, the response will still be
211
+ # sitting in the socket waiting to be read. If the response we just read
212
+ # was to a previous request, we can safely skip it.
213
+ if correlation_id < @correlation_id
214
+ @logger.error "Received out-of-order response id #{correlation_id}, was expecting #{@correlation_id}"
215
+ elsif correlation_id > @correlation_id
216
+ raise Kafka::Error, "Correlation id mismatch: expected #{@correlation_id} but got #{correlation_id}"
217
+ else
218
+ return response
219
+ end
220
+ end
221
+ end
222
+ end
223
+ 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,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