ruby-kafka 0.7.0 → 0.7.1.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +3 -3
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +4 -0
  5. data/lib/kafka.rb +32 -0
  6. data/lib/kafka/broker.rb +18 -0
  7. data/lib/kafka/client.rb +38 -4
  8. data/lib/kafka/cluster.rb +60 -37
  9. data/lib/kafka/consumer.rb +2 -2
  10. data/lib/kafka/fetch_operation.rb +18 -59
  11. data/lib/kafka/fetched_batch.rb +9 -9
  12. data/lib/kafka/fetched_batch_generator.rb +114 -0
  13. data/lib/kafka/fetched_offset_resolver.rb +48 -0
  14. data/lib/kafka/fetcher.rb +2 -2
  15. data/lib/kafka/produce_operation.rb +52 -14
  16. data/lib/kafka/producer.rb +82 -2
  17. data/lib/kafka/protocol.rb +68 -48
  18. data/lib/kafka/protocol/add_partitions_to_txn_request.rb +34 -0
  19. data/lib/kafka/protocol/add_partitions_to_txn_response.rb +47 -0
  20. data/lib/kafka/protocol/decoder.rb +3 -6
  21. data/lib/kafka/protocol/encoder.rb +6 -11
  22. data/lib/kafka/protocol/end_txn_request.rb +29 -0
  23. data/lib/kafka/protocol/end_txn_response.rb +19 -0
  24. data/lib/kafka/protocol/fetch_request.rb +3 -1
  25. data/lib/kafka/protocol/fetch_response.rb +37 -18
  26. data/lib/kafka/protocol/init_producer_id_request.rb +26 -0
  27. data/lib/kafka/protocol/init_producer_id_response.rb +27 -0
  28. data/lib/kafka/protocol/list_offset_request.rb +8 -2
  29. data/lib/kafka/protocol/list_offset_response.rb +11 -6
  30. data/lib/kafka/protocol/record.rb +9 -0
  31. data/lib/kafka/protocol/record_batch.rb +17 -1
  32. data/lib/kafka/ssl_context.rb +19 -5
  33. data/lib/kafka/transaction_manager.rb +261 -0
  34. data/lib/kafka/transaction_state_machine.rb +72 -0
  35. data/lib/kafka/version.rb +1 -1
  36. data/ruby-kafka.gemspec +1 -1
  37. metadata +20 -4
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kafka
4
+ module Protocol
5
+ class InitProducerIDResponse
6
+ attr_reader :error_code, :producer_id, :producer_epoch
7
+
8
+ def initialize(error_code:, producer_id:, producer_epoch:)
9
+ @error_code = error_code
10
+ @producer_id = producer_id
11
+ @producer_epoch = producer_epoch
12
+ end
13
+
14
+ def self.decode(decoder)
15
+ _throttle_time_ms = decoder.int32
16
+ error_code = decoder.int16
17
+ producer_id = decoder.int64
18
+ producer_epoch = decoder.int16
19
+ new(
20
+ error_code: error_code,
21
+ producer_id: producer_id,
22
+ producer_epoch: producer_epoch
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -8,12 +8,14 @@ module Kafka
8
8
  #
9
9
  # OffsetRequest => ReplicaId [TopicName [Partition Time MaxNumberOfOffsets]]
10
10
  # ReplicaId => int32
11
+ # IsolationLevel => int8
11
12
  # TopicName => string
12
13
  # Partition => int32
13
14
  # Time => int64
14
- # MaxNumberOfOffsets => int32
15
15
  #
16
16
  class ListOffsetRequest
17
+ ISOLATION_READ_UNCOMMITTED = 0
18
+ ISOLATION_READ_COMMITTED = 1
17
19
 
18
20
  # @param topics [Hash]
19
21
  def initialize(topics:)
@@ -21,6 +23,10 @@ module Kafka
21
23
  @topics = topics
22
24
  end
23
25
 
26
+ def api_version
27
+ 2
28
+ end
29
+
24
30
  def api_key
25
31
  LIST_OFFSET_API
26
32
  end
@@ -31,6 +37,7 @@ module Kafka
31
37
 
32
38
  def encode(encoder)
33
39
  encoder.write_int32(@replica_id)
40
+ encoder.write_int8(ISOLATION_READ_COMMITTED)
34
41
 
35
42
  encoder.write_array(@topics) do |topic, partitions|
36
43
  encoder.write_string(topic)
@@ -38,7 +45,6 @@ module Kafka
38
45
  encoder.write_array(partitions) do |partition|
39
46
  encoder.write_int32(partition.fetch(:partition))
40
47
  encoder.write_int64(partition.fetch(:time))
41
- encoder.write_int32(partition.fetch(:max_offsets))
42
48
  end
43
49
  end
44
50
  end
@@ -8,9 +8,11 @@ module Kafka
8
8
  # ## API Specification
9
9
  #
10
10
  # OffsetResponse => [TopicName [PartitionOffsets]]
11
- # PartitionOffsets => Partition ErrorCode [Offset]
11
+ # ThrottleTimeMS => int32
12
+ # PartitionOffsets => Partition ErrorCode Timestamp Offset
12
13
  # Partition => int32
13
14
  # ErrorCode => int16
15
+ # Timestamp => int64
14
16
  # Offset => int64
15
17
  #
16
18
  class ListOffsetResponse
@@ -24,12 +26,13 @@ module Kafka
24
26
  end
25
27
 
26
28
  class PartitionOffsetInfo
27
- attr_reader :partition, :error_code, :offsets
29
+ attr_reader :partition, :error_code, :timestamp, :offset
28
30
 
29
- def initialize(partition:, error_code:, offsets:)
31
+ def initialize(partition:, error_code:, timestamp:, offset:)
30
32
  @partition = partition
31
33
  @error_code = error_code
32
- @offsets = offsets
34
+ @timestamp = timestamp
35
+ @offset = offset
33
36
  end
34
37
  end
35
38
 
@@ -56,10 +59,11 @@ module Kafka
56
59
 
57
60
  Protocol.handle_error(partition_info.error_code)
58
61
 
59
- partition_info.offsets.first
62
+ partition_info.offset
60
63
  end
61
64
 
62
65
  def self.decode(decoder)
66
+ _throttle_time_ms = decoder.int32
63
67
  topics = decoder.array do
64
68
  name = decoder.string
65
69
 
@@ -67,7 +71,8 @@ module Kafka
67
71
  PartitionOffsetInfo.new(
68
72
  partition: decoder.int32,
69
73
  error_code: decoder.int16,
70
- offsets: decoder.array { decoder.int64 },
74
+ timestamp: decoder.int64,
75
+ offset: decoder.int64
71
76
  )
72
77
  end
73
78
 
@@ -10,6 +10,7 @@ module Kafka
10
10
  headers: {},
11
11
  attributes: 0,
12
12
  offset_delta: 0,
13
+ offset: 0,
13
14
  timestamp_delta: 0,
14
15
  create_time: Time.now,
15
16
  is_control_record: false
@@ -20,6 +21,7 @@ module Kafka
20
21
  @attributes = attributes
21
22
 
22
23
  @offset_delta = offset_delta
24
+ @offset = offset
23
25
  @timestamp_delta = timestamp_delta
24
26
  @create_time = create_time
25
27
  @is_control_record = is_control_record
@@ -47,6 +49,13 @@ module Kafka
47
49
  encoder.write_varint_bytes(record_buffer.string)
48
50
  end
49
51
 
52
+ def ==(other)
53
+ offset_delta == other.offset_delta &&
54
+ timestamp_delta == other.timestamp_delta &&
55
+ offset == other.offset &&
56
+ is_control_record == other.is_control_record
57
+ end
58
+
50
59
  def self.decode(decoder)
51
60
  record_decoder = Decoder.from_string(decoder.varint_bytes)
52
61
 
@@ -30,7 +30,7 @@ module Kafka
30
30
  first_sequence: 0,
31
31
  max_timestamp: Time.now
32
32
  )
33
- @records = records
33
+ @records = Array(records)
34
34
  @first_offset = first_offset
35
35
  @first_timestamp = first_timestamp
36
36
  @codec_id = codec_id
@@ -55,6 +55,10 @@ module Kafka
55
55
  @records.size
56
56
  end
57
57
 
58
+ def last_offset
59
+ @first_offset + @last_offset_delta
60
+ end
61
+
58
62
  def attributes
59
63
  0x0000 | @codec_id |
60
64
  (@in_transaction ? IN_TRANSACTION_MASK : 0x0) |
@@ -131,6 +135,18 @@ module Kafka
131
135
  @last_offset_delta = records.length - 1
132
136
  end
133
137
 
138
+ def ==(other)
139
+ records == other.records &&
140
+ first_offset == other.first_offset &&
141
+ partition_leader_epoch == other.partition_leader_epoch &&
142
+ in_transaction == other.in_transaction &&
143
+ is_control_batch == other.is_control_batch &&
144
+ last_offset_delta == other.last_offset_delta &&
145
+ producer_id == other.producer_id &&
146
+ producer_epoch == other.producer_epoch &&
147
+ first_sequence == other.first_sequence
148
+ end
149
+
134
150
  def self.decode(decoder)
135
151
  first_offset = decoder.int64
136
152
 
@@ -4,21 +4,35 @@ require "openssl"
4
4
 
5
5
  module Kafka
6
6
  module SslContext
7
+ CLIENT_CERT_DELIMITER = "\n-----END CERTIFICATE-----\n"
7
8
 
8
- def self.build(ca_cert_file_path: nil, ca_cert: nil, client_cert: nil, client_cert_key: nil, ca_certs_from_system: nil)
9
- return nil unless ca_cert_file_path || ca_cert || client_cert || client_cert_key || ca_certs_from_system
9
+ def self.build(ca_cert_file_path: nil, ca_cert: nil, client_cert: nil, client_cert_key: nil, client_cert_chain: nil, ca_certs_from_system: nil)
10
+ return nil unless ca_cert_file_path || ca_cert || client_cert || client_cert_key || client_cert_chain || ca_certs_from_system
10
11
 
11
12
  ssl_context = OpenSSL::SSL::SSLContext.new
12
13
 
13
14
  if client_cert && client_cert_key
14
- ssl_context.set_params(
15
+ context_params = {
15
16
  cert: OpenSSL::X509::Certificate.new(client_cert),
16
- key: OpenSSL::PKey.read(client_cert_key)
17
- )
17
+ key: OpenSSL::PKey.read(client_cert_key),
18
+ }
19
+ if client_cert_chain
20
+ certs = []
21
+ client_cert_chain.split(CLIENT_CERT_DELIMITER).each do |cert|
22
+ cert += CLIENT_CERT_DELIMITER
23
+ certs << OpenSSL::X509::Certificate.new(cert)
24
+ end
25
+ context_params[:extra_chain_cert] = certs
26
+ end
27
+ ssl_context.set_params(context_params)
18
28
  elsif client_cert && !client_cert_key
19
29
  raise ArgumentError, "Kafka client initialized with `ssl_client_cert` but no `ssl_client_cert_key`. Please provide both."
20
30
  elsif !client_cert && client_cert_key
21
31
  raise ArgumentError, "Kafka client initialized with `ssl_client_cert_key`, but no `ssl_client_cert`. Please provide both."
32
+ elsif client_cert_chain && !client_cert
33
+ raise ArgumentError, "Kafka client initialized with `ssl_client_cert_chain`, but no `ssl_client_cert`. Please provide cert, key and chain."
34
+ elsif client_cert_chain && !client_cert_key
35
+ raise ArgumentError, "Kafka client initialized with `ssl_client_cert_chain`, but no `ssl_client_cert_key`. Please provide cert, key and chain."
22
36
  end
23
37
 
24
38
  if ca_cert || ca_cert_file_path || ca_certs_from_system
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kafka/transaction_state_machine'
4
+
5
+ module Kafka
6
+ class TransactionManager
7
+ DEFAULT_TRANSACTION_TIMEOUT = 60 # 60 seconds
8
+ TRANSACTION_RESULT_COMMIT = true
9
+ TRANSACTION_RESULT_ABORT = false
10
+
11
+ attr_reader :producer_id, :producer_epoch, :transactional_id
12
+
13
+ def initialize(
14
+ cluster:,
15
+ logger:,
16
+ idempotent: false,
17
+ transactional: false,
18
+ transactional_id: nil,
19
+ transactional_timeout: DEFAULT_TRANSACTION_TIMEOUT
20
+ )
21
+ @cluster = cluster
22
+ @logger = logger
23
+
24
+ @transactional = transactional
25
+ @transactional_id = transactional_id
26
+ @transactional_timeout = transactional_timeout
27
+ @transaction_state = Kafka::TransactionStateMachine.new(logger: logger)
28
+ @transaction_partitions = {}
29
+
30
+ # If transactional mode is enabled, idempotent must be enabled
31
+ @idempotent = transactional || idempotent
32
+
33
+ @producer_id = -1
34
+ @producer_epoch = 0
35
+
36
+ @sequences = {}
37
+ end
38
+
39
+ def idempotent?
40
+ @idempotent == true
41
+ end
42
+
43
+ def transactional?
44
+ @transactional == true && !@transactional_id.nil?
45
+ end
46
+
47
+ def init_producer_id(force = false)
48
+ return if @producer_id >= 0 && !force
49
+
50
+ response = transaction_coordinator.init_producer_id(
51
+ transactional_id: @transactional_id,
52
+ transactional_timeout: @transactional_timeout
53
+ )
54
+ Protocol.handle_error(response.error_code)
55
+
56
+ # Reset producer id
57
+ @producer_id = response.producer_id
58
+ @producer_epoch = response.producer_epoch
59
+
60
+ # Reset sequence
61
+ @sequences = {}
62
+
63
+ @logger.debug "Current Producer ID is #{@producer_id} and Producer Epoch is #{@producer_epoch}"
64
+ end
65
+
66
+ def next_sequence_for(topic, partition)
67
+ @sequences[topic] ||= {}
68
+ @sequences[topic][partition] ||= 0
69
+ end
70
+
71
+ def update_sequence_for(topic, partition, sequence)
72
+ @sequences[topic] ||= {}
73
+ @sequences[topic][partition] = sequence
74
+ end
75
+
76
+ def init_transactions
77
+ force_transactional!
78
+ unless @transaction_state.uninitialized?
79
+ @logger.warn("Transaction already initialized!")
80
+ return
81
+ end
82
+ init_producer_id(true)
83
+ @transaction_partitions = {}
84
+ @transaction_state.transition_to!(TransactionStateMachine::READY)
85
+
86
+ @logger.info "Transaction #{@transactional_id} is initialized, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
87
+
88
+ nil
89
+ rescue
90
+ @transaction_state.transition_to!(TransactionStateMachine::ERROR)
91
+ raise
92
+ end
93
+
94
+ def add_partitions_to_transaction(topic_partitions)
95
+ force_transactional!
96
+
97
+ if @transaction_state.uninitialized?
98
+ raise 'Transaction is uninitialized'
99
+ end
100
+
101
+ # Extract newly created partitions
102
+ new_topic_partitions = {}
103
+ topic_partitions.each do |topic, partitions|
104
+ partitions.each do |partition|
105
+ @transaction_partitions[topic] ||= {}
106
+ if !@transaction_partitions[topic][partition]
107
+ new_topic_partitions[topic] ||= []
108
+ new_topic_partitions[topic] << partition
109
+
110
+ @logger.info "Adding parition #{topic}/#{partition} to transaction #{@transactional_id}, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
111
+ end
112
+ end
113
+ end
114
+
115
+ unless new_topic_partitions.empty?
116
+ response = transaction_coordinator.add_partitions_to_txn(
117
+ transactional_id: @transactional_id,
118
+ producer_id: @producer_id,
119
+ producer_epoch: @producer_epoch,
120
+ topics: new_topic_partitions
121
+ )
122
+
123
+ # Update added topic partitions
124
+ response.errors.each do |tp|
125
+ tp.partitions.each do |p|
126
+ Protocol.handle_error(p.error_code)
127
+ @transaction_partitions[tp.topic] ||= {}
128
+ @transaction_partitions[tp.topic][p.partition] = true
129
+ end
130
+ end
131
+ end
132
+
133
+ nil
134
+ rescue
135
+ @transaction_state.transition_to!(TransactionStateMachine::ERROR)
136
+ raise
137
+ end
138
+
139
+ def begin_transaction
140
+ force_transactional!
141
+ raise 'Transaction has already started' if @transaction_state.in_transaction?
142
+ raise 'Transaction is not ready' unless @transaction_state.ready?
143
+ @transaction_state.transition_to!(TransactionStateMachine::IN_TRANSACTION)
144
+
145
+ @logger.info "Begin transaction #{@transactional_id}, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
146
+
147
+ nil
148
+ rescue
149
+ @transaction_state.transition_to!(TransactionStateMachine::ERROR)
150
+ raise
151
+ end
152
+
153
+ def commit_transaction
154
+ force_transactional!
155
+
156
+ if @transaction_state.committing_transaction?
157
+ @logger.warn("Transaction is being committed")
158
+ return
159
+ end
160
+
161
+ unless @transaction_state.in_transaction?
162
+ raise 'Transaction is not valid to commit'
163
+ end
164
+
165
+ @transaction_state.transition_to!(TransactionStateMachine::COMMITTING_TRANSACTION)
166
+
167
+ @logger.info "Commiting transaction #{@transactional_id}, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
168
+
169
+ response = transaction_coordinator.end_txn(
170
+ transactional_id: @transactional_id,
171
+ producer_id: @producer_id,
172
+ producer_epoch: @producer_epoch,
173
+ transaction_result: TRANSACTION_RESULT_COMMIT
174
+ )
175
+ Protocol.handle_error(response.error_code)
176
+
177
+ @logger.info "Transaction #{@transactional_id} is committed, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
178
+ complete_transaction
179
+
180
+ nil
181
+ rescue
182
+ @transaction_state.transition_to!(TransactionStateMachine::ERROR)
183
+ raise
184
+ end
185
+
186
+ def abort_transaction
187
+ force_transactional!
188
+
189
+ if @transaction_state.aborting_transaction?
190
+ @logger.warn("Transaction is being aborted")
191
+ return
192
+ end
193
+
194
+ unless @transaction_state.in_transaction?
195
+ raise 'Transaction is not valid to abort'
196
+ end
197
+
198
+ @transaction_state.transition_to!(TransactionStateMachine::ABORTING_TRANSACTION)
199
+
200
+ @logger.info "Aborting transaction #{@transactional_id}, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
201
+
202
+ response = transaction_coordinator.end_txn(
203
+ transactional_id: @transactional_id,
204
+ producer_id: @producer_id,
205
+ producer_epoch: @producer_epoch,
206
+ transaction_result: TRANSACTION_RESULT_ABORT
207
+ )
208
+ Protocol.handle_error(response.error_code)
209
+
210
+ @logger.info "Transaction #{@transactional_id} is aborted, Producer ID: #{@producer_id} (Epoch #{@producer_epoch})"
211
+
212
+ complete_transaction
213
+
214
+ nil
215
+ rescue
216
+ @transaction_state.transition_to!(TransactionStateMachine::ERROR)
217
+ raise
218
+ end
219
+
220
+ def in_transaction?
221
+ @transaction_state.in_transaction?
222
+ end
223
+
224
+ def error?
225
+ @transaction_state.error?
226
+ end
227
+
228
+ def close
229
+ if in_transaction?
230
+ @logger.warn("Aborting pending transaction ...")
231
+ abort_transaction
232
+ elsif @transaction_state.aborting_transaction? || @transaction_state.committing_transaction?
233
+ @logger.warn("Transaction is finishing. Sleeping until finish!")
234
+ sleep 5
235
+ end
236
+ end
237
+
238
+ private
239
+
240
+ def force_transactional!
241
+ unless transactional?
242
+ raise 'Please turn on transactional mode to use transaction'
243
+ end
244
+
245
+ if @transactional_id.nil? || @transactional_id.empty?
246
+ raise 'Please provide a transaction_id to use transactional mode'
247
+ end
248
+ end
249
+
250
+ def transaction_coordinator
251
+ @cluster.get_transaction_coordinator(
252
+ transactional_id: @transactional_id
253
+ )
254
+ end
255
+
256
+ def complete_transaction
257
+ @transaction_state.transition_to!(TransactionStateMachine::READY)
258
+ @transaction_partitions = {}
259
+ end
260
+ end
261
+ end