ruby-kafka 0.7.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +102 -3
  3. data/.github/workflows/stale.yml +19 -0
  4. data/CHANGELOG.md +24 -0
  5. data/README.md +18 -0
  6. data/lib/kafka/async_producer.rb +3 -0
  7. data/lib/kafka/broker.rb +12 -0
  8. data/lib/kafka/client.rb +35 -3
  9. data/lib/kafka/cluster.rb +52 -0
  10. data/lib/kafka/compression.rb +13 -11
  11. data/lib/kafka/compressor.rb +1 -0
  12. data/lib/kafka/connection.rb +3 -0
  13. data/lib/kafka/consumer_group.rb +4 -1
  14. data/lib/kafka/datadog.rb +2 -10
  15. data/lib/kafka/fetched_batch.rb +5 -1
  16. data/lib/kafka/fetched_batch_generator.rb +4 -1
  17. data/lib/kafka/fetched_message.rb +1 -0
  18. data/lib/kafka/fetcher.rb +4 -1
  19. data/lib/kafka/gzip_codec.rb +4 -0
  20. data/lib/kafka/lz4_codec.rb +4 -0
  21. data/lib/kafka/producer.rb +20 -1
  22. data/lib/kafka/prometheus.rb +316 -0
  23. data/lib/kafka/protocol.rb +8 -0
  24. data/lib/kafka/protocol/add_offsets_to_txn_request.rb +29 -0
  25. data/lib/kafka/protocol/add_offsets_to_txn_response.rb +19 -0
  26. data/lib/kafka/protocol/join_group_request.rb +8 -2
  27. data/lib/kafka/protocol/offset_fetch_request.rb +3 -1
  28. data/lib/kafka/protocol/produce_request.rb +3 -1
  29. data/lib/kafka/protocol/record_batch.rb +5 -4
  30. data/lib/kafka/protocol/txn_offset_commit_request.rb +46 -0
  31. data/lib/kafka/protocol/txn_offset_commit_response.rb +18 -0
  32. data/lib/kafka/sasl/scram.rb +15 -12
  33. data/lib/kafka/snappy_codec.rb +4 -0
  34. data/lib/kafka/ssl_context.rb +4 -1
  35. data/lib/kafka/ssl_socket_with_timeout.rb +1 -0
  36. data/lib/kafka/tagged_logger.rb +25 -20
  37. data/lib/kafka/transaction_manager.rb +25 -0
  38. data/lib/kafka/version.rb +1 -1
  39. data/lib/kafka/zstd_codec.rb +27 -0
  40. data/ruby-kafka.gemspec +4 -2
  41. metadata +47 -6
@@ -33,7 +33,9 @@ module Kafka
33
33
  DELETE_TOPICS_API = 20
34
34
  INIT_PRODUCER_ID_API = 22
35
35
  ADD_PARTITIONS_TO_TXN_API = 24
36
+ ADD_OFFSETS_TO_TXN_API = 25
36
37
  END_TXN_API = 26
38
+ TXN_OFFSET_COMMIT_API = 28
37
39
  DESCRIBE_CONFIGS_API = 32
38
40
  ALTER_CONFIGS_API = 33
39
41
  CREATE_PARTITIONS_API = 37
@@ -57,7 +59,9 @@ module Kafka
57
59
  DELETE_TOPICS_API => :delete_topics,
58
60
  INIT_PRODUCER_ID_API => :init_producer_id_api,
59
61
  ADD_PARTITIONS_TO_TXN_API => :add_partitions_to_txn_api,
62
+ ADD_OFFSETS_TO_TXN_API => :add_offsets_to_txn_api,
60
63
  END_TXN_API => :end_txn_api,
64
+ TXN_OFFSET_COMMIT_API => :txn_offset_commit_api,
61
65
  DESCRIBE_CONFIGS_API => :describe_configs_api,
62
66
  CREATE_PARTITIONS_API => :create_partitions
63
67
  }
@@ -177,6 +181,10 @@ require "kafka/protocol/fetch_request"
177
181
  require "kafka/protocol/fetch_response"
178
182
  require "kafka/protocol/list_offset_request"
179
183
  require "kafka/protocol/list_offset_response"
184
+ require "kafka/protocol/add_offsets_to_txn_request"
185
+ require "kafka/protocol/add_offsets_to_txn_response"
186
+ require "kafka/protocol/txn_offset_commit_request"
187
+ require "kafka/protocol/txn_offset_commit_response"
180
188
  require "kafka/protocol/find_coordinator_request"
181
189
  require "kafka/protocol/find_coordinator_response"
182
190
  require "kafka/protocol/join_group_request"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kafka
4
+ module Protocol
5
+ class AddOffsetsToTxnRequest
6
+ def initialize(transactional_id: nil, producer_id:, producer_epoch:, group_id:)
7
+ @transactional_id = transactional_id
8
+ @producer_id = producer_id
9
+ @producer_epoch = producer_epoch
10
+ @group_id = group_id
11
+ end
12
+
13
+ def api_key
14
+ ADD_OFFSETS_TO_TXN_API
15
+ end
16
+
17
+ def response_class
18
+ AddOffsetsToTxnResponse
19
+ end
20
+
21
+ def encode(encoder)
22
+ encoder.write_string(@transactional_id.to_s)
23
+ encoder.write_int64(@producer_id)
24
+ encoder.write_int16(@producer_epoch)
25
+ encoder.write_string(@group_id)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ module Kafka
2
+ module Protocol
3
+ class AddOffsetsToTxnResponse
4
+
5
+ attr_reader :error_code
6
+
7
+ def initialize(error_code:)
8
+ @error_code = error_code
9
+ end
10
+
11
+ def self.decode(decoder)
12
+ _throttle_time_ms = decoder.int32
13
+ error_code = decoder.int16
14
+ new(error_code: error_code)
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -7,13 +7,14 @@ module Kafka
7
7
  class JoinGroupRequest
8
8
  PROTOCOL_TYPE = "consumer"
9
9
 
10
- def initialize(group_id:, session_timeout:, member_id:, topics: [])
10
+ def initialize(group_id:, session_timeout:, rebalance_timeout:, member_id:, topics: [])
11
11
  @group_id = group_id
12
12
  @session_timeout = session_timeout * 1000 # Kafka wants ms.
13
+ @rebalance_timeout = rebalance_timeout * 1000 # Kafka wants ms.
13
14
  @member_id = member_id || ""
14
15
  @protocol_type = PROTOCOL_TYPE
15
16
  @group_protocols = {
16
- "standard" => ConsumerGroupProtocol.new(topics: ["test-messages"]),
17
+ "roundrobin" => ConsumerGroupProtocol.new(topics: topics),
17
18
  }
18
19
  end
19
20
 
@@ -21,6 +22,10 @@ module Kafka
21
22
  JOIN_GROUP_API
22
23
  end
23
24
 
25
+ def api_version
26
+ 1
27
+ end
28
+
24
29
  def response_class
25
30
  JoinGroupResponse
26
31
  end
@@ -28,6 +33,7 @@ module Kafka
28
33
  def encode(encoder)
29
34
  encoder.write_string(@group_id)
30
35
  encoder.write_int32(@session_timeout)
36
+ encoder.write_int32(@rebalance_timeout)
31
37
  encoder.write_string(@member_id)
32
38
  encoder.write_string(@protocol_type)
33
39
 
@@ -12,8 +12,10 @@ module Kafka
12
12
  OFFSET_FETCH_API
13
13
  end
14
14
 
15
+ # setting topics to nil fetches all offsets for a consumer group
16
+ # and that feature is only available in API version 2+
15
17
  def api_version
16
- 1
18
+ @topics.nil? ? 2 : 1
17
19
  end
18
20
 
19
21
  def response_class
@@ -27,6 +27,8 @@ module Kafka
27
27
  # Value => bytes
28
28
  #
29
29
  class ProduceRequest
30
+ API_MIN_VERSION = 3
31
+
30
32
  attr_reader :transactional_id, :required_acks, :timeout, :messages_for_topics, :compressor
31
33
 
32
34
  # @param required_acks [Integer]
@@ -45,7 +47,7 @@ module Kafka
45
47
  end
46
48
 
47
49
  def api_version
48
- 3
50
+ compressor.codec.nil? ? API_MIN_VERSION : [compressor.codec.produce_api_min_version, API_MIN_VERSION].max
49
51
  end
50
52
 
51
53
  def response_class
@@ -1,3 +1,4 @@
1
+ require 'bigdecimal'
1
2
  require 'digest/crc32'
2
3
  require 'kafka/protocol/record'
3
4
 
@@ -131,7 +132,7 @@ module Kafka
131
132
 
132
133
  records.each_with_index do |record, index|
133
134
  record.offset_delta = index
134
- record.timestamp_delta = (record.create_time - first_timestamp).to_i
135
+ record.timestamp_delta = ((record.create_time - first_timestamp) * 1000).to_i
135
136
  end
136
137
  @last_offset_delta = records.length - 1
137
138
  end
@@ -167,8 +168,8 @@ module Kafka
167
168
  log_append_time = (attributes & TIMESTAMP_TYPE_MASK) != 0
168
169
 
169
170
  last_offset_delta = record_batch_decoder.int32
170
- first_timestamp = Time.at(record_batch_decoder.int64 / 1000)
171
- max_timestamp = Time.at(record_batch_decoder.int64 / 1000)
171
+ first_timestamp = Time.at(record_batch_decoder.int64 / BigDecimal(1000))
172
+ max_timestamp = Time.at(record_batch_decoder.int64 / BigDecimal(1000))
172
173
 
173
174
  producer_id = record_batch_decoder.int64
174
175
  producer_epoch = record_batch_decoder.int16
@@ -188,7 +189,7 @@ module Kafka
188
189
  until records_array_decoder.eof?
189
190
  record = Record.decode(records_array_decoder)
190
191
  record.offset = first_offset + record.offset_delta
191
- record.create_time = log_append_time && max_timestamp ? max_timestamp : first_timestamp + record.timestamp_delta
192
+ record.create_time = log_append_time && max_timestamp ? max_timestamp : first_timestamp + record.timestamp_delta / BigDecimal(1000)
192
193
  records_array << record
193
194
  end
194
195
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kafka
4
+ module Protocol
5
+ class TxnOffsetCommitRequest
6
+
7
+ def api_key
8
+ TXN_OFFSET_COMMIT_API
9
+ end
10
+
11
+ def api_version
12
+ 2
13
+ end
14
+
15
+ def response_class
16
+ TxnOffsetCommitResponse
17
+ end
18
+
19
+ def initialize(transactional_id:, group_id:, producer_id:, producer_epoch:, offsets:)
20
+ @transactional_id = transactional_id
21
+ @producer_id = producer_id
22
+ @producer_epoch = producer_epoch
23
+ @group_id = group_id
24
+ @offsets = offsets
25
+ end
26
+
27
+ def encode(encoder)
28
+ encoder.write_string(@transactional_id.to_s)
29
+ encoder.write_string(@group_id)
30
+ encoder.write_int64(@producer_id)
31
+ encoder.write_int16(@producer_epoch)
32
+
33
+ encoder.write_array(@offsets) do |topic, partitions|
34
+ encoder.write_string(topic)
35
+ encoder.write_array(partitions) do |partition, offset|
36
+ encoder.write_int32(partition)
37
+ encoder.write_int64(offset[:offset])
38
+ encoder.write_string(nil) # metadata
39
+ encoder.write_int32(offset[:leader_epoch])
40
+ end
41
+ end
42
+ end
43
+
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ module Kafka
2
+ module Protocol
3
+ class TxnOffsetCommitResponse
4
+
5
+ attr_reader :error_code
6
+
7
+ def initialize(error_code:)
8
+ @error_code = error_code
9
+ end
10
+
11
+ def self.decode(decoder)
12
+ _throttle_time_ms = decoder.int32
13
+ error_code = decoder.int16
14
+ new(error_code: error_code)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -12,6 +12,7 @@ module Kafka
12
12
  }.freeze
13
13
 
14
14
  def initialize(username:, password:, mechanism: 'sha256', logger:)
15
+ @semaphore = Mutex.new
15
16
  @username = username
16
17
  @password = password
17
18
  @logger = TaggedLogger.new(logger)
@@ -35,22 +36,24 @@ module Kafka
35
36
  @logger.debug "Authenticating #{@username} with SASL #{@mechanism}"
36
37
 
37
38
  begin
38
- msg = first_message
39
- @logger.debug "Sending first client SASL SCRAM message: #{msg}"
40
- encoder.write_bytes(msg)
39
+ @semaphore.synchronize do
40
+ msg = first_message
41
+ @logger.debug "Sending first client SASL SCRAM message: #{msg}"
42
+ encoder.write_bytes(msg)
41
43
 
42
- @server_first_message = decoder.bytes
43
- @logger.debug "Received first server SASL SCRAM message: #{@server_first_message}"
44
+ @server_first_message = decoder.bytes
45
+ @logger.debug "Received first server SASL SCRAM message: #{@server_first_message}"
44
46
 
45
- msg = final_message
46
- @logger.debug "Sending final client SASL SCRAM message: #{msg}"
47
- encoder.write_bytes(msg)
47
+ msg = final_message
48
+ @logger.debug "Sending final client SASL SCRAM message: #{msg}"
49
+ encoder.write_bytes(msg)
48
50
 
49
- response = parse_response(decoder.bytes)
50
- @logger.debug "Received last server SASL SCRAM message: #{response}"
51
+ response = parse_response(decoder.bytes)
52
+ @logger.debug "Received last server SASL SCRAM message: #{response}"
51
53
 
52
- raise FailedScramAuthentication, response['e'] if response['e']
53
- raise FailedScramAuthentication, "Invalid server signature" if response['v'] != server_signature
54
+ raise FailedScramAuthentication, response['e'] if response['e']
55
+ raise FailedScramAuthentication, "Invalid server signature" if response['v'] != server_signature
56
+ end
54
57
  rescue EOFError => e
55
58
  raise FailedScramAuthentication, e.message
56
59
  end
@@ -6,6 +6,10 @@ module Kafka
6
6
  2
7
7
  end
8
8
 
9
+ def produce_api_min_version
10
+ 0
11
+ end
12
+
9
13
  def load
10
14
  require "snappy"
11
15
  rescue LoadError
@@ -6,7 +6,7 @@ module Kafka
6
6
  module SslContext
7
7
  CLIENT_CERT_DELIMITER = "\n-----END CERTIFICATE-----\n"
8
8
 
9
- def self.build(ca_cert_file_path: nil, ca_cert: nil, client_cert: nil, client_cert_key: nil, client_cert_key_password: nil, client_cert_chain: nil, ca_certs_from_system: nil)
9
+ def self.build(ca_cert_file_path: nil, ca_cert: nil, client_cert: nil, client_cert_key: nil, client_cert_key_password: nil, client_cert_chain: nil, ca_certs_from_system: nil, verify_hostname: true)
10
10
  return nil unless ca_cert_file_path || ca_cert || client_cert || client_cert_key || client_cert_key_password || client_cert_chain || ca_certs_from_system
11
11
 
12
12
  ssl_context = OpenSSL::SSL::SSLContext.new
@@ -54,6 +54,9 @@ module Kafka
54
54
  store.set_default_paths
55
55
  end
56
56
  ssl_context.cert_store = store
57
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
58
+ # Verify certificate hostname if supported (ruby >= 2.4.0)
59
+ ssl_context.verify_hostname = verify_hostname if ssl_context.respond_to?(:verify_hostname=)
57
60
  end
58
61
 
59
62
  ssl_context
@@ -57,6 +57,7 @@ module Kafka
57
57
 
58
58
  # once that's connected, we can start initiating the ssl socket
59
59
  @ssl_socket = OpenSSL::SSL::SSLSocket.new(@tcp_socket, ssl_context)
60
+ @ssl_socket.hostname = host
60
61
 
61
62
  begin
62
63
  # Initiate the socket connection in the background. If it doesn't fail
@@ -1,13 +1,20 @@
1
- require 'forwardable'
2
-
3
1
  # Basic implementation of a tagged logger that matches the API of
4
2
  # ActiveSupport::TaggedLogging.
5
3
 
4
+ require 'delegate'
5
+ require 'logger'
6
+
6
7
  module Kafka
7
- module TaggedFormatter
8
+ class TaggedLogger < SimpleDelegator
8
9
 
9
- def call(severity, timestamp, progname, msg)
10
- super(severity, timestamp, progname, "#{tags_text}#{msg}")
10
+ %i(debug info warn error).each do |method|
11
+ define_method method do |msg_or_progname, &block|
12
+ if block_given?
13
+ super(msg_or_progname, &block)
14
+ else
15
+ super("#{tags_text}#{msg_or_progname}")
16
+ end
17
+ end
11
18
  end
12
19
 
13
20
  def tagged(*tags)
@@ -44,23 +51,21 @@ module Kafka
44
51
  end
45
52
  end
46
53
 
47
- end
48
-
49
- module TaggedLogger
50
- extend Forwardable
51
- delegate [:push_tags, :pop_tags, :clear_tags!] => :formatter
52
-
53
- def self.new(logger)
54
- logger ||= Logger.new(nil)
55
- return logger if logger.respond_to?(:push_tags) # already included
56
- # Ensure we set a default formatter so we aren't extending nil!
57
- logger.formatter ||= Logger::Formatter.new
58
- logger.formatter.extend TaggedFormatter
59
- logger.extend(self)
54
+ def self.new(logger_or_stream = nil)
55
+ # don't keep wrapping the same logger over and over again
56
+ return logger_or_stream if logger_or_stream.is_a?(TaggedLogger)
57
+ super
60
58
  end
61
59
 
62
- def tagged(*tags)
63
- formatter.tagged(*tags) { yield self }
60
+ def initialize(logger_or_stream = nil)
61
+ logger = if %w(info debug warn error).all? { |s| logger_or_stream.respond_to?(s) }
62
+ logger_or_stream
63
+ elsif logger_or_stream
64
+ ::Logger.new(logger_or_stream)
65
+ else
66
+ ::Logger.new(nil)
67
+ end
68
+ super(logger)
64
69
  end
65
70
 
66
71
  def flush
@@ -217,6 +217,31 @@ module Kafka
217
217
  raise
218
218
  end
219
219
 
220
+ def send_offsets_to_txn(offsets:, group_id:)
221
+ force_transactional!
222
+
223
+ unless @transaction_state.in_transaction?
224
+ raise 'Transaction is not valid to send offsets'
225
+ end
226
+
227
+ add_response = transaction_coordinator.add_offsets_to_txn(
228
+ transactional_id: @transactional_id,
229
+ producer_id: @producer_id,
230
+ producer_epoch: @producer_epoch,
231
+ group_id: group_id
232
+ )
233
+ Protocol.handle_error(add_response.error_code)
234
+
235
+ send_response = transaction_coordinator.txn_offset_commit(
236
+ transactional_id: @transactional_id,
237
+ group_id: group_id,
238
+ producer_id: @producer_id,
239
+ producer_epoch: @producer_epoch,
240
+ offsets: offsets
241
+ )
242
+ Protocol.handle_error(send_response.error_code)
243
+ end
244
+
220
245
  def in_transaction?
221
246
  @transaction_state.in_transaction?
222
247
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kafka
4
- VERSION = "0.7.6"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kafka
4
+ class ZstdCodec
5
+ def codec_id
6
+ 4
7
+ end
8
+
9
+ def produce_api_min_version
10
+ 7
11
+ end
12
+
13
+ def load
14
+ require "zstd-ruby"
15
+ rescue LoadError
16
+ raise LoadError, "using zstd compression requires adding a dependency on the `zstd-ruby` gem to your Gemfile."
17
+ end
18
+
19
+ def compress(data)
20
+ Zstd.compress(data)
21
+ end
22
+
23
+ def decompress(data)
24
+ Zstd.decompress(data)
25
+ end
26
+ end
27
+ end