ruby-kafka 0.7.0 → 0.7.1.beta1

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 (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