ruby-kafka 0.1.0.pre.beta1 → 0.1.0.pre.beta2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +25 -2
- data/bin/console +2 -9
- data/bin/setup +0 -2
- data/circle.yml +3 -0
- data/kafka.gemspec +1 -0
- data/lib/kafka.rb +103 -12
- data/lib/kafka/broker_pool.rb +44 -72
- data/lib/kafka/client.rb +14 -0
- data/lib/kafka/connection.rb +18 -4
- data/lib/kafka/message_buffer.rb +64 -0
- data/lib/kafka/partitioner.rb +16 -1
- data/lib/kafka/producer.rb +181 -43
- data/lib/kafka/protocol.rb +27 -12
- data/lib/kafka/protocol/metadata_response.rb +1 -1
- data/lib/kafka/protocol/produce_response.rb +8 -0
- data/lib/kafka/version.rb +1 -1
- metadata +18 -7
- data/.travis.yml +0 -4
- data/docker-compose.yml +0 -35
- data/lib/kafka/message.rb +0 -12
- data/lib/kafka/message_set.rb +0 -24
- data/test-setup.sh +0 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6d8c3294bd19a1c201194ff051f6d98d7a6dc273
|
4
|
+
data.tar.gz: 1769607ad012c3913b1ad707922ec271cc7a4567
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 02b14e2350078b1555cadf35a5de7eba1ca5f218a65ddfdef7e9e76f8a7f7089c85a8a1ae40623566f37ee1675c63354a240c9ce97c3e7797180b137155de2fe
|
7
|
+
data.tar.gz: 603e25244557ba9bd684c5f30543654b224ffabfb7bde6eff59ed88805e6618d89f846afcf40eb2113c9fbc44d02f57ab7c825ee03c9535c3f0be139739a8725
|
data/README.md
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
A Ruby client library for the Kafka distributed log system. The focus of this library will be operational simplicity, with good logging and metrics that can make debugging issues easier.
|
4
4
|
|
5
|
-
This library is still in pre-
|
5
|
+
This library is still in pre-beta stage, but development is ongoing. Current efforts are focused on implementing a solid Producer client. The next step will be implementing a client for the Kafka 0.9 Consumer API.
|
6
6
|
|
7
7
|
## Installation
|
8
8
|
|
@@ -22,13 +22,18 @@ Or install it yourself as:
|
|
22
22
|
|
23
23
|
## Usage
|
24
24
|
|
25
|
+
Currently, only the Producer API is supported. A Kafka 0.9 compatible Consumer API is on the roadmap.
|
26
|
+
|
25
27
|
```ruby
|
28
|
+
# The client must be initialized with at least one Kafka broker.
|
26
29
|
kafka = Kafka.new(
|
27
30
|
seed_brokers: ["kafka1:9092", "kafka2:9092"],
|
28
31
|
client_id: "my-app",
|
29
32
|
logger: Logger.new($stderr),
|
30
33
|
)
|
31
34
|
|
35
|
+
# Each producer keeps a separate pool of broker connections. Don't use the same
|
36
|
+
# producer from more than one thread.
|
32
37
|
producer = kafka.get_producer
|
33
38
|
|
34
39
|
# `write` will buffer the message in the producer.
|
@@ -43,7 +48,25 @@ producer.flush
|
|
43
48
|
|
44
49
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
45
50
|
|
46
|
-
|
51
|
+
**Note:** the specs require a working [Docker](https://www.docker.com/) instance, but should work out of the box if you have Docker installed. Please create an issue if that's not the case.
|
52
|
+
|
53
|
+
## Roadmap
|
54
|
+
|
55
|
+
v0.1 is targeted for release in February. Other milestones do not have firm target dates, but v0.2 will be released as soon as we are confident that it is ready to run in critical production environments and that the API shouldn't be changed.
|
56
|
+
|
57
|
+
### v0.1: Producer API for non-critical production data
|
58
|
+
|
59
|
+
We need to actually run this in production for a while before we can say that it won't lose data, so initially the library should only be deployed for non-critical use cases.
|
60
|
+
|
61
|
+
The API may also be changed.
|
62
|
+
|
63
|
+
### v0.2: Stable Producer API
|
64
|
+
|
65
|
+
The API should now have stabilized and the library should be battle tested enough to deploy for critical use cases.
|
66
|
+
|
67
|
+
### v1.0: Consumer API
|
68
|
+
|
69
|
+
The Consumer API defined by Kafka 0.9 will be implemented.
|
47
70
|
|
48
71
|
## Contributing
|
49
72
|
|
data/bin/console
CHANGED
@@ -3,12 +3,5 @@
|
|
3
3
|
require "bundler/setup"
|
4
4
|
require "kafka"
|
5
5
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
-
# require "pry"
|
11
|
-
# Pry.start
|
12
|
-
|
13
|
-
require "irb"
|
14
|
-
IRB.start
|
6
|
+
require "pry"
|
7
|
+
Pry.start
|
data/bin/setup
CHANGED
data/circle.yml
ADDED
data/kafka.gemspec
CHANGED
data/lib/kafka.rb
CHANGED
@@ -1,22 +1,113 @@
|
|
1
1
|
require "kafka/version"
|
2
|
-
require "kafka/client"
|
3
2
|
|
4
3
|
module Kafka
|
5
|
-
Error
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
4
|
+
class Error < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
# Subclasses of this exception class map to an error code described in the
|
8
|
+
# Kafka protocol specification.
|
9
|
+
#
|
10
|
+
# See https://cwiki.apache.org/confluence/display/KAFKA/A+Guide+To+The+Kafka+Protocol
|
11
|
+
class ProtocolError < StandardError
|
12
|
+
end
|
13
|
+
|
14
|
+
# This indicates that a message contents does not match its CRC.
|
15
|
+
class CorruptMessage < ProtocolError
|
16
|
+
end
|
17
|
+
|
18
|
+
class UnknownError < ProtocolError
|
19
|
+
end
|
20
|
+
|
21
|
+
class OffsetOutOfRange < ProtocolError
|
22
|
+
end
|
23
|
+
|
24
|
+
# The request is for a topic or partition that does not exist on the broker.
|
25
|
+
class UnknownTopicOrPartition < ProtocolError
|
26
|
+
end
|
27
|
+
|
28
|
+
# The message has a negative size.
|
29
|
+
class InvalidMessageSize < ProtocolError
|
30
|
+
end
|
31
|
+
|
32
|
+
# This error is thrown if we are in the middle of a leadership election and
|
33
|
+
# there is currently no leader for this partition and hence it is unavailable
|
34
|
+
# for writes.
|
35
|
+
class LeaderNotAvailable < ProtocolError
|
36
|
+
end
|
37
|
+
|
38
|
+
# This error is thrown if the client attempts to send messages to a replica
|
39
|
+
# that is not the leader for some partition. It indicates that the client's
|
40
|
+
# metadata is out of date.
|
41
|
+
class NotLeaderForPartition < ProtocolError
|
42
|
+
end
|
43
|
+
|
44
|
+
# This error is thrown if the request exceeds the user-specified time limit
|
45
|
+
# in the request.
|
46
|
+
class RequestTimedOut < ProtocolError
|
47
|
+
end
|
48
|
+
|
49
|
+
class BrokerNotAvailable < ProtocolError
|
50
|
+
end
|
51
|
+
|
52
|
+
# The server has a configurable maximum message size to avoid unbounded memory
|
53
|
+
# allocation. This error is thrown if the client attempt to produce a message
|
54
|
+
# larger than this maximum.
|
55
|
+
class MessageSizeTooLarge < ProtocolError
|
56
|
+
end
|
57
|
+
|
58
|
+
# If you specify a string larger than configured maximum for offset metadata.
|
59
|
+
class OffsetMetadataTooLarge < ProtocolError
|
60
|
+
end
|
61
|
+
|
62
|
+
# For a request which attempts to access an invalid topic (e.g. one which has
|
63
|
+
# an illegal name), or if an attempt is made to write to an internal topic
|
64
|
+
# (such as the consumer offsets topic).
|
65
|
+
class InvalidTopic < ProtocolError
|
66
|
+
end
|
67
|
+
|
68
|
+
# If a message batch in a produce request exceeds the maximum configured
|
69
|
+
# segment size.
|
70
|
+
class RecordListTooLarge < ProtocolError
|
71
|
+
end
|
72
|
+
|
73
|
+
# Returned from a produce request when the number of in-sync replicas is
|
74
|
+
# lower than the configured minimum and requiredAcks is -1.
|
75
|
+
class NotEnoughReplicas < ProtocolError
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returned from a produce request when the message was written to the log,
|
79
|
+
# but with fewer in-sync replicas than required.
|
80
|
+
class NotEnoughReplicasAfterAppend < ProtocolError
|
81
|
+
end
|
82
|
+
|
83
|
+
# Returned from a produce request if the requested requiredAcks is invalid
|
84
|
+
# (anything other than -1, 1, or 0).
|
85
|
+
class InvalidRequiredAcks < ProtocolError
|
86
|
+
end
|
15
87
|
|
16
88
|
# Raised if a replica is expected on a broker, but is not. Can be safely ignored.
|
17
|
-
ReplicaNotAvailable
|
89
|
+
class ReplicaNotAvailable < ProtocolError
|
90
|
+
end
|
18
91
|
|
92
|
+
# Raised when there's a network connection error.
|
93
|
+
class ConnectionError < Error
|
94
|
+
end
|
95
|
+
|
96
|
+
# Raised when a producer buffer has reached its maximum size.
|
97
|
+
class BufferOverflow < Error
|
98
|
+
end
|
99
|
+
|
100
|
+
# Raised if not all messages could be sent by a producer.
|
101
|
+
class FailedToSendMessages < Error
|
102
|
+
end
|
103
|
+
|
104
|
+
# Initializes a new Kafka client.
|
105
|
+
#
|
106
|
+
# @see Client#initialize
|
107
|
+
# @return [Client]
|
19
108
|
def self.new(**options)
|
20
109
|
Client.new(**options)
|
21
110
|
end
|
22
111
|
end
|
112
|
+
|
113
|
+
require "kafka/client"
|
data/lib/kafka/broker_pool.rb
CHANGED
@@ -8,12 +8,6 @@ module Kafka
|
|
8
8
|
# partitions to the current leader for those partitions.
|
9
9
|
class BrokerPool
|
10
10
|
|
11
|
-
# The number of times to try to connect to a broker before giving up.
|
12
|
-
MAX_CONNECTION_ATTEMPTS = 3
|
13
|
-
|
14
|
-
# The backoff period between connection retries, in seconds.
|
15
|
-
RETRY_BACKOFF_TIMEOUT = 5
|
16
|
-
|
17
11
|
# Initializes a broker pool with a set of seed brokers.
|
18
12
|
#
|
19
13
|
# The pool will try to fetch cluster metadata from one of the brokers.
|
@@ -27,19 +21,52 @@ module Kafka
|
|
27
21
|
@socket_timeout = socket_timeout
|
28
22
|
@brokers = {}
|
29
23
|
@seed_brokers = seed_brokers
|
24
|
+
@cluster_info = nil
|
25
|
+
end
|
30
26
|
|
31
|
-
|
27
|
+
def mark_as_stale!
|
28
|
+
@cluster_info = nil
|
32
29
|
end
|
33
30
|
|
34
|
-
#
|
31
|
+
# Finds the broker acting as the leader of the given topic and partition.
|
32
|
+
#
|
33
|
+
# @param topic [String]
|
34
|
+
# @param partition [Integer]
|
35
|
+
# @return [Integer] the broker id.
|
36
|
+
def get_leader_id(topic, partition)
|
37
|
+
cluster_info.find_leader_id(topic, partition)
|
38
|
+
end
|
39
|
+
|
40
|
+
def get_broker(broker_id)
|
41
|
+
@brokers[broker_id] ||= connect_to_broker(broker_id)
|
42
|
+
end
|
43
|
+
|
44
|
+
def partitions_for(topic)
|
45
|
+
cluster_info.partitions_for(topic)
|
46
|
+
end
|
47
|
+
|
48
|
+
def shutdown
|
49
|
+
@brokers.each do |id, broker|
|
50
|
+
@logger.info "Disconnecting broker #{id}"
|
51
|
+
broker.disconnect
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def cluster_info
|
58
|
+
@cluster_info ||= fetch_cluster_info
|
59
|
+
end
|
60
|
+
|
61
|
+
# Fetches the cluster metadata.
|
35
62
|
#
|
36
63
|
# This is used to update the partition leadership information, among other things.
|
37
|
-
# The methods will go through each node listed in
|
64
|
+
# The methods will go through each node listed in +seed_brokers+, connecting to the
|
38
65
|
# first one that is available. This node will be queried for the cluster metadata.
|
39
66
|
#
|
40
|
-
# @raise [ConnectionError] if none of the nodes in
|
41
|
-
# @return [
|
42
|
-
def
|
67
|
+
# @raise [ConnectionError] if none of the nodes in +seed_brokers+ are available.
|
68
|
+
# @return [Protocol::MetadataResponse] the cluster metadata.
|
69
|
+
def fetch_cluster_info
|
43
70
|
@seed_brokers.each do |node|
|
44
71
|
@logger.info "Trying to initialize broker pool from node #{node}"
|
45
72
|
|
@@ -54,76 +81,21 @@ module Kafka
|
|
54
81
|
logger: @logger,
|
55
82
|
)
|
56
83
|
|
57
|
-
|
84
|
+
cluster_info = broker.fetch_metadata
|
58
85
|
|
59
|
-
@logger.info "Initialized broker pool with brokers: #{
|
86
|
+
@logger.info "Initialized broker pool with brokers: #{cluster_info.brokers.inspect}"
|
60
87
|
|
61
|
-
return
|
88
|
+
return cluster_info
|
62
89
|
rescue Error => e
|
63
|
-
@logger.error "Failed to fetch metadata from
|
90
|
+
@logger.error "Failed to fetch metadata from #{node}: #{e}"
|
64
91
|
end
|
65
92
|
end
|
66
93
|
|
67
94
|
raise ConnectionError, "Could not connect to any of the seed brokers: #{@seed_brokers.inspect}"
|
68
95
|
end
|
69
96
|
|
70
|
-
# Finds the broker acting as the leader of the given topic and partition and connects to it.
|
71
|
-
#
|
72
|
-
# Note that this call may take a considerable amount of time, since the cached cluster
|
73
|
-
# metadata may be out of date. In that case, the cluster needs to be re-discovered. This
|
74
|
-
# can happen when a broker becomes unavailable, which would trigger a leader election for
|
75
|
-
# the partitions previously owned by that broker. Since this can take some time, this method
|
76
|
-
# will retry up to `MAX_CONNECTION_ATTEMPTS` times, waiting `RETRY_BACKOFF_TIMEOUT` seconds
|
77
|
-
# between each attempt.
|
78
|
-
#
|
79
|
-
# @param topic [String]
|
80
|
-
# @param partition [Integer]
|
81
|
-
# @raise [ConnectionError] if it was not possible to connect to the leader.
|
82
|
-
# @return [Broker] the broker that's currently acting as leader of the partition.
|
83
|
-
def get_leader(topic, partition)
|
84
|
-
attempt = 0
|
85
|
-
|
86
|
-
begin
|
87
|
-
leader_id = @cluster_info.find_leader_id(topic, partition)
|
88
|
-
broker_for_id(leader_id)
|
89
|
-
rescue ConnectionError => e
|
90
|
-
@logger.error "Failed to connect to leader for topic `#{topic}`, partition #{partition}"
|
91
|
-
|
92
|
-
if attempt < MAX_CONNECTION_ATTEMPTS
|
93
|
-
attempt += 1
|
94
|
-
|
95
|
-
@logger.info "Rediscovering cluster and retrying"
|
96
|
-
|
97
|
-
sleep RETRY_BACKOFF_TIMEOUT
|
98
|
-
refresh
|
99
|
-
retry
|
100
|
-
else
|
101
|
-
@logger.error "Giving up trying to find leader for topic `#{topic}`, partition #{partition}"
|
102
|
-
|
103
|
-
raise e
|
104
|
-
end
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
def partitions_for(topic)
|
109
|
-
@cluster_info.partitions_for(topic)
|
110
|
-
end
|
111
|
-
|
112
|
-
def shutdown
|
113
|
-
@brokers.each do |id, broker|
|
114
|
-
@logger.info "Disconnecting broker #{id}"
|
115
|
-
broker.disconnect
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
|
-
private
|
120
|
-
|
121
|
-
def broker_for_id(broker_id)
|
122
|
-
@brokers[broker_id] ||= connect_to_broker(broker_id)
|
123
|
-
end
|
124
|
-
|
125
97
|
def connect_to_broker(broker_id)
|
126
|
-
broker_info =
|
98
|
+
broker_info = cluster_info.find_broker(broker_id)
|
127
99
|
|
128
100
|
Broker.connect(
|
129
101
|
host: broker_info.host,
|
data/lib/kafka/client.rb
CHANGED
@@ -3,6 +3,20 @@ require "kafka/producer"
|
|
3
3
|
|
4
4
|
module Kafka
|
5
5
|
class Client
|
6
|
+
|
7
|
+
# Initializes a new Kafka client.
|
8
|
+
#
|
9
|
+
# @param seed_brokers [Array<String>] the list of brokers used to initialize
|
10
|
+
# the client.
|
11
|
+
#
|
12
|
+
# @param client_id [String] the identifier for this application.
|
13
|
+
#
|
14
|
+
# @param logger [Logger]
|
15
|
+
#
|
16
|
+
# @param socket_timeout [Integer, nil] the timeout setting for socket
|
17
|
+
# connections. See {BrokerPool#initialize}.
|
18
|
+
#
|
19
|
+
# @return [Client]
|
6
20
|
def initialize(seed_brokers:, client_id:, logger:, socket_timeout: nil)
|
7
21
|
@seed_brokers = seed_brokers
|
8
22
|
@client_id = client_id
|
data/lib/kafka/connection.rb
CHANGED
@@ -44,7 +44,7 @@ module Kafka
|
|
44
44
|
|
45
45
|
# Correlation id is initialized to zero and bumped for each request.
|
46
46
|
@correlation_id = 0
|
47
|
-
rescue Errno::ETIMEDOUT
|
47
|
+
rescue Errno::ETIMEDOUT => e
|
48
48
|
@logger.error "Timed out while trying to connect to #{host}:#{port}: #{e}"
|
49
49
|
raise ConnectionError, e
|
50
50
|
rescue SocketError, Errno::ECONNREFUSED => e
|
@@ -67,12 +67,26 @@ module Kafka
|
|
67
67
|
# @param request [#encode] the request that should be encoded and written.
|
68
68
|
# @param response_class [#decode] an object that can decode the response.
|
69
69
|
#
|
70
|
-
# @return [Object] the response that was decoded by
|
70
|
+
# @return [Object] the response that was decoded by +response_class+.
|
71
71
|
def request(api_key, request, response_class)
|
72
72
|
write_request(api_key, request)
|
73
73
|
|
74
74
|
unless response_class.nil?
|
75
|
-
|
75
|
+
loop do
|
76
|
+
correlation_id, response = read_response(response_class)
|
77
|
+
|
78
|
+
# There may have been a previous request that timed out before the client
|
79
|
+
# was able to read the response. In that case, the response will still be
|
80
|
+
# sitting in the socket waiting to be read. If the response we just read
|
81
|
+
# was to a previous request, we can safely skip it.
|
82
|
+
if correlation_id < @correlation_id
|
83
|
+
@logger.error "Received out-of-order response id #{correlation_id}, was expecting #{@correlation_id}"
|
84
|
+
elsif correlation_id > @correlation_id
|
85
|
+
raise Kafka::Error, "Correlation id mismatch: expected #{@correlation_id} but got #{correlation_id}"
|
86
|
+
else
|
87
|
+
break response
|
88
|
+
end
|
89
|
+
end
|
76
90
|
end
|
77
91
|
end
|
78
92
|
|
@@ -131,7 +145,7 @@ module Kafka
|
|
131
145
|
|
132
146
|
@logger.debug "Received response #{correlation_id} from #{to_s}"
|
133
147
|
|
134
|
-
response
|
148
|
+
return correlation_id, response
|
135
149
|
end
|
136
150
|
end
|
137
151
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Kafka
|
2
|
+
|
3
|
+
# Buffers messages for specific topics/partitions.
|
4
|
+
class MessageBuffer
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@buffer = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def write(message, topic:, partition:)
|
12
|
+
buffer_for(topic, partition) << message
|
13
|
+
end
|
14
|
+
|
15
|
+
def concat(messages, topic:, partition:)
|
16
|
+
buffer_for(topic, partition).concat(messages)
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_h
|
20
|
+
@buffer
|
21
|
+
end
|
22
|
+
|
23
|
+
def size
|
24
|
+
@buffer.values.inject(0) {|sum, messages| messages.values.flatten.size + sum }
|
25
|
+
end
|
26
|
+
|
27
|
+
def empty?
|
28
|
+
@buffer.empty?
|
29
|
+
end
|
30
|
+
|
31
|
+
def each
|
32
|
+
@buffer.each do |topic, messages_for_topic|
|
33
|
+
messages_for_topic.each do |partition, messages_for_partition|
|
34
|
+
yield topic, partition, messages_for_partition
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Clears buffered messages for the given topic and partition.
|
40
|
+
#
|
41
|
+
# @param topic [String] the name of the topic.
|
42
|
+
# @param partition [Integer] the partition id.
|
43
|
+
#
|
44
|
+
# @return [nil]
|
45
|
+
def clear_messages(topic:, partition:)
|
46
|
+
@buffer[topic].delete(partition)
|
47
|
+
@buffer.delete(topic) if @buffer[topic].empty?
|
48
|
+
end
|
49
|
+
|
50
|
+
# Clears messages across all topics and partitions.
|
51
|
+
#
|
52
|
+
# @return [nil]
|
53
|
+
def clear
|
54
|
+
@buffer = {}
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def buffer_for(topic, partition)
|
60
|
+
@buffer[topic] ||= {}
|
61
|
+
@buffer[topic][partition] ||= []
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/kafka/partitioner.rb
CHANGED
@@ -1,13 +1,28 @@
|
|
1
1
|
require "zlib"
|
2
2
|
|
3
3
|
module Kafka
|
4
|
+
|
5
|
+
# Assigns partitions to messages.
|
4
6
|
class Partitioner
|
5
7
|
def initialize(partitions)
|
6
8
|
@partitions = partitions
|
7
9
|
end
|
8
10
|
|
11
|
+
# Assigns a partition number based on a key.
|
12
|
+
#
|
13
|
+
# If the key is nil, then a random partition is selected. Otherwise, a digest
|
14
|
+
# of the key is used to deterministically find a partition. As long as the
|
15
|
+
# number of partitions doesn't change, the same key will always be assigned
|
16
|
+
# to the same partition.
|
17
|
+
#
|
18
|
+
# @param key [String, nil] the key to base the partition assignment on, or nil.
|
19
|
+
# @return [Integer] the partition number.
|
9
20
|
def partition_for_key(key)
|
10
|
-
|
21
|
+
if key.nil?
|
22
|
+
rand(@partitions.count)
|
23
|
+
else
|
24
|
+
Zlib.crc32(key) % @partitions.count
|
25
|
+
end
|
11
26
|
end
|
12
27
|
end
|
13
28
|
end
|
data/lib/kafka/producer.rb
CHANGED
@@ -1,19 +1,68 @@
|
|
1
|
-
require "kafka/message"
|
2
|
-
require "kafka/message_set"
|
3
1
|
require "kafka/partitioner"
|
2
|
+
require "kafka/message_buffer"
|
3
|
+
require "kafka/protocol/message"
|
4
4
|
|
5
5
|
module Kafka
|
6
|
+
|
7
|
+
# Allows sending messages to a Kafka cluster.
|
8
|
+
#
|
9
|
+
# == Buffering
|
10
|
+
#
|
11
|
+
# The producer buffers pending messages until {#flush} is called. Note that there is
|
12
|
+
# a maximum buffer size (default is 1,000 messages) and writing messages after the
|
13
|
+
# buffer has reached this size will result in a BufferOverflow exception. Make sure
|
14
|
+
# to periodically call {#flush} or set +max_buffer_size+ to an appropriate value.
|
15
|
+
#
|
16
|
+
# Buffering messages and sending them in batches greatly improves performance, so
|
17
|
+
# try to avoid flushing after every write. The tradeoff between throughput and
|
18
|
+
# message delays depends on your use case.
|
19
|
+
#
|
20
|
+
# == Error Handling and Retries
|
21
|
+
#
|
22
|
+
# The design of the error handling is based on having a {MessageBuffer} hold messages
|
23
|
+
# for all topics/partitions. Whenever we want to flush messages to the cluster, we
|
24
|
+
# group the buffered messages by the broker they need to be sent to and fire off a
|
25
|
+
# request to each broker. A request can be a partial success, so we go through the
|
26
|
+
# response and inspect the error code for each partition that we wrote to. If the
|
27
|
+
# write to a given partition was successful, we clear the corresponding messages
|
28
|
+
# from the buffer -- otherwise, we log the error and keep the messages in the buffer.
|
29
|
+
#
|
30
|
+
# After this, we check if the buffer is empty. If it is, we're all done. If it's
|
31
|
+
# not, we do another round of requests, this time with just the remaining messages.
|
32
|
+
# We do this for as long as +max_retries+ permits.
|
33
|
+
#
|
6
34
|
class Producer
|
7
|
-
|
8
|
-
#
|
35
|
+
|
36
|
+
# Initializes a new Producer.
|
37
|
+
#
|
38
|
+
# @param broker_pool [BrokerPool] the broker pool representing the cluster.
|
39
|
+
#
|
40
|
+
# @param logger [Logger]
|
41
|
+
#
|
42
|
+
# @param timeout [Integer] The number of seconds a broker can wait for
|
43
|
+
# replicas to acknowledge a write before responding with a timeout.
|
44
|
+
#
|
9
45
|
# @param required_acks [Integer] The number of replicas that must acknowledge
|
10
46
|
# a write.
|
11
|
-
|
47
|
+
#
|
48
|
+
# @param max_retries [Integer] the number of retries that should be attempted
|
49
|
+
# before giving up sending messages to the cluster. Does not include the
|
50
|
+
# original attempt.
|
51
|
+
#
|
52
|
+
# @param retry_backoff [Integer] the number of seconds to wait between retries.
|
53
|
+
#
|
54
|
+
# @param max_buffer_size [Integer] the number of messages allowed in the buffer
|
55
|
+
# before new writes will raise BufferOverflow exceptions.
|
56
|
+
#
|
57
|
+
def initialize(broker_pool:, logger:, timeout: 10, required_acks: 1, max_retries: 2, retry_backoff: 1, max_buffer_size: 1000)
|
12
58
|
@broker_pool = broker_pool
|
13
59
|
@logger = logger
|
14
60
|
@required_acks = required_acks
|
15
61
|
@timeout = timeout
|
16
|
-
@
|
62
|
+
@max_retries = max_retries
|
63
|
+
@retry_backoff = retry_backoff
|
64
|
+
@max_buffer_size = max_buffer_size
|
65
|
+
@buffer = MessageBuffer.new
|
17
66
|
end
|
18
67
|
|
19
68
|
# Writes a message to the specified topic. Note that messages are buffered in
|
@@ -22,13 +71,20 @@ module Kafka
|
|
22
71
|
# == Partitioning
|
23
72
|
#
|
24
73
|
# There are several options for specifying the partition that the message should
|
25
|
-
# be written to.
|
26
|
-
#
|
27
|
-
#
|
28
|
-
#
|
29
|
-
#
|
30
|
-
#
|
31
|
-
# the
|
74
|
+
# be written to.
|
75
|
+
#
|
76
|
+
# The simplest option is to not specify a message key, partition key, or
|
77
|
+
# partition number, in which case the message will be assigned a partition at
|
78
|
+
# random.
|
79
|
+
#
|
80
|
+
# You can also specify the +partition+ parameter yourself. This requires you to
|
81
|
+
# know which partitions are available, however. Oftentimes the best option is
|
82
|
+
# to specify the +partition_key+ parameter: messages with the same partition
|
83
|
+
# key will always be assigned to the same partition, as long as the number of
|
84
|
+
# partitions doesn't change. You can also omit the partition key and specify
|
85
|
+
# a message key instead. The message key is part of the message payload, and
|
86
|
+
# so can carry semantic value--whether you want to have the message key double
|
87
|
+
# as a partition key is up to you.
|
32
88
|
#
|
33
89
|
# @param value [String] the message data.
|
34
90
|
# @param key [String] the message key.
|
@@ -36,8 +92,13 @@ module Kafka
|
|
36
92
|
# @param partition [Integer] the partition that the message should be written to.
|
37
93
|
# @param partition_key [String] the key that should be used to assign a partition.
|
38
94
|
#
|
39
|
-
# @
|
40
|
-
|
95
|
+
# @raise [BufferOverflow] if the maximum buffer size has been reached.
|
96
|
+
# @return [nil]
|
97
|
+
def write(value, key: nil, topic:, partition: nil, partition_key: nil)
|
98
|
+
unless buffer_size < @max_buffer_size
|
99
|
+
raise BufferOverflow, "Max buffer size #{@max_buffer_size} exceeded"
|
100
|
+
end
|
101
|
+
|
41
102
|
if partition.nil?
|
42
103
|
# If no explicit partition key is specified we use the message key instead.
|
43
104
|
partition_key ||= key
|
@@ -45,58 +106,135 @@ module Kafka
|
|
45
106
|
partition = partitioner.partition_for_key(partition_key)
|
46
107
|
end
|
47
108
|
|
48
|
-
message = Message.new(
|
109
|
+
message = Protocol::Message.new(key: key, value: value)
|
49
110
|
|
50
|
-
@
|
111
|
+
@buffer.write(message, topic: topic, partition: partition)
|
51
112
|
|
52
|
-
|
113
|
+
partition
|
53
114
|
end
|
54
115
|
|
55
116
|
# Flushes all messages to the Kafka brokers.
|
56
117
|
#
|
57
|
-
# Depending on the value of
|
118
|
+
# Depending on the value of +required_acks+ used when initializing the producer,
|
58
119
|
# this call may block until the specified number of replicas have acknowledged
|
59
|
-
# the writes. The
|
120
|
+
# the writes. The +timeout+ setting places an upper bound on the amount of time
|
60
121
|
# the call will block before failing.
|
61
122
|
#
|
123
|
+
# @raise [FailedToSendMessages] if not all messages could be successfully sent.
|
62
124
|
# @return [nil]
|
63
125
|
def flush
|
126
|
+
attempt = 0
|
127
|
+
|
128
|
+
loop do
|
129
|
+
@logger.info "Flushing #{@buffer.size} messages"
|
130
|
+
|
131
|
+
attempt += 1
|
132
|
+
transmit_messages
|
133
|
+
|
134
|
+
if @buffer.empty?
|
135
|
+
@logger.info "Successfully transmitted all messages"
|
136
|
+
break
|
137
|
+
elsif attempt <= @max_retries
|
138
|
+
@logger.warn "Failed to transmit all messages, retry #{attempt} of #{@max_retries}"
|
139
|
+
@logger.info "Waiting #{@retry_backoff}s before retrying"
|
140
|
+
|
141
|
+
sleep @retry_backoff
|
142
|
+
else
|
143
|
+
@logger.error "Failed to transmit all messages; keeping remaining messages in buffer"
|
144
|
+
break
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
if @required_acks == 0
|
149
|
+
# No response is returned by the brokers, so we can't know which messages
|
150
|
+
# have been successfully written. Our only option is to assume that they all
|
151
|
+
# have.
|
152
|
+
@buffer.clear
|
153
|
+
end
|
154
|
+
|
155
|
+
unless @buffer.empty?
|
156
|
+
partitions = @buffer.map {|topic, partition, _| "#{topic}/#{partition}" }.join(", ")
|
157
|
+
|
158
|
+
raise FailedToSendMessages, "Failed to send messages to #{partitions}"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Returns the number of messages currently held in the buffer.
|
163
|
+
#
|
164
|
+
# @return [Integer] buffer size.
|
165
|
+
def buffer_size
|
166
|
+
@buffer.size
|
167
|
+
end
|
168
|
+
|
169
|
+
def shutdown
|
170
|
+
@broker_pool.shutdown
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
def transmit_messages
|
64
176
|
messages_for_broker = {}
|
65
177
|
|
66
|
-
@
|
67
|
-
|
178
|
+
@buffer.each do |topic, partition, messages|
|
179
|
+
broker_id = @broker_pool.get_leader_id(topic, partition)
|
68
180
|
|
69
|
-
|
70
|
-
|
181
|
+
@logger.debug "Current leader for #{topic}/#{partition} is node #{broker_id}"
|
182
|
+
|
183
|
+
messages_for_broker[broker_id] ||= MessageBuffer.new
|
184
|
+
messages_for_broker[broker_id].concat(messages, topic: topic, partition: partition)
|
71
185
|
end
|
72
186
|
|
73
|
-
messages_for_broker.each do |
|
74
|
-
|
187
|
+
messages_for_broker.each do |broker_id, message_set|
|
188
|
+
begin
|
189
|
+
broker = @broker_pool.get_broker(broker_id)
|
75
190
|
|
76
|
-
|
191
|
+
response = broker.produce(
|
192
|
+
messages_for_topics: message_set.to_h,
|
193
|
+
required_acks: @required_acks,
|
194
|
+
timeout: @timeout * 1000, # Kafka expects the timeout in milliseconds.
|
195
|
+
)
|
77
196
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
timeout: @timeout * 1000, # Kafka expects the timeout in milliseconds.
|
82
|
-
)
|
197
|
+
handle_response(response) if response
|
198
|
+
rescue ConnectionError => e
|
199
|
+
@logger.error "Could not connect to broker #{broker_id}: #{e}"
|
83
200
|
|
84
|
-
|
85
|
-
|
86
|
-
topic_info.partitions.each do |partition_info|
|
87
|
-
Protocol.handle_error(partition_info.error_code)
|
88
|
-
end
|
89
|
-
end
|
201
|
+
# Mark the broker pool as stale in order to force a cluster metadata refresh.
|
202
|
+
@broker_pool.mark_as_stale!
|
90
203
|
end
|
91
204
|
end
|
205
|
+
end
|
92
206
|
|
93
|
-
|
207
|
+
def handle_response(response)
|
208
|
+
response.each_partition do |topic_info, partition_info|
|
209
|
+
topic = topic_info.topic
|
210
|
+
partition = partition_info.partition
|
94
211
|
|
95
|
-
|
96
|
-
|
212
|
+
begin
|
213
|
+
Protocol.handle_error(partition_info.error_code)
|
214
|
+
rescue Kafka::CorruptMessage
|
215
|
+
@logger.error "Corrupt message when writing to #{topic}/#{partition}"
|
216
|
+
rescue Kafka::UnknownTopicOrPartition
|
217
|
+
@logger.error "Unknown topic or partition #{topic}/#{partition}"
|
218
|
+
rescue Kafka::LeaderNotAvailable
|
219
|
+
@logger.error "Leader currently not available for #{topic}/#{partition}"
|
220
|
+
@broker_pool.mark_as_stale!
|
221
|
+
rescue Kafka::NotLeaderForPartition
|
222
|
+
@logger.error "Broker not currently leader for #{topic}/#{partition}"
|
223
|
+
@broker_pool.mark_as_stale!
|
224
|
+
rescue Kafka::RequestTimedOut
|
225
|
+
@logger.error "Timed out while writing to #{topic}/#{partition}"
|
226
|
+
rescue Kafka::NotEnoughReplicas
|
227
|
+
@logger.error "Not enough in-sync replicas for #{topic}/#{partition}"
|
228
|
+
rescue Kafka::NotEnoughReplicasAfterAppend
|
229
|
+
@logger.error "Messages written, but to fewer in-sync replicas than required for #{topic}/#{partition}"
|
230
|
+
else
|
231
|
+
offset = partition_info.offset
|
232
|
+
@logger.info "Successfully flushed messages for #{topic}/#{partition}; new offset is #{offset}"
|
97
233
|
|
98
|
-
|
99
|
-
|
234
|
+
# The messages were successfully written; clear them from the buffer.
|
235
|
+
@buffer.clear_messages(topic: topic, partition: partition)
|
236
|
+
end
|
237
|
+
end
|
100
238
|
end
|
101
239
|
end
|
102
240
|
end
|
data/lib/kafka/protocol.rb
CHANGED
@@ -3,19 +3,34 @@ module Kafka
|
|
3
3
|
PRODUCE_API_KEY = 0
|
4
4
|
TOPIC_METADATA_API_KEY = 3
|
5
5
|
|
6
|
+
ERRORS = {
|
7
|
+
-1 => UnknownError,
|
8
|
+
1 => OffsetOutOfRange,
|
9
|
+
2 => CorruptMessage,
|
10
|
+
3 => UnknownTopicOrPartition,
|
11
|
+
4 => InvalidMessageSize,
|
12
|
+
5 => LeaderNotAvailable,
|
13
|
+
6 => NotLeaderForPartition,
|
14
|
+
7 => RequestTimedOut,
|
15
|
+
8 => BrokerNotAvailable,
|
16
|
+
9 => ReplicaNotAvailable,
|
17
|
+
10 => MessageSizeTooLarge,
|
18
|
+
12 => OffsetMetadataTooLarge,
|
19
|
+
17 => InvalidTopic,
|
20
|
+
18 => RecordListTooLarge,
|
21
|
+
19 => NotEnoughReplicas,
|
22
|
+
20 => NotEnoughReplicasAfterAppend,
|
23
|
+
21 => InvalidRequiredAcks,
|
24
|
+
}
|
25
|
+
|
26
|
+
|
6
27
|
def self.handle_error(error_code)
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
when 4 then raise InvalidMessageSize
|
14
|
-
when 5 then raise LeaderNotAvailable
|
15
|
-
when 6 then raise NotLeaderForPartition
|
16
|
-
when 7 then raise RequestTimedOut
|
17
|
-
when 9 then raise ReplicaNotAvailable
|
18
|
-
else raise UnknownError, "Unknown error with code #{error_code}"
|
28
|
+
if error_code == 0
|
29
|
+
# No errors, yay!
|
30
|
+
elsif error = ERRORS[error_code]
|
31
|
+
raise error
|
32
|
+
else
|
33
|
+
raise UnknownError, "Unknown error with code #{error_code}"
|
19
34
|
end
|
20
35
|
end
|
21
36
|
end
|
@@ -8,7 +8,7 @@ module Kafka
|
|
8
8
|
#
|
9
9
|
# * For each broker a node id, host, and port is provided.
|
10
10
|
# * For each topic partition the node id of the broker acting as partition leader,
|
11
|
-
# as well as a list of node ids for the set of replicas, are given. The
|
11
|
+
# as well as a list of node ids for the set of replicas, are given. The +isr+ list is
|
12
12
|
# the subset of replicas that are "in sync", i.e. have fully caught up with the
|
13
13
|
# leader.
|
14
14
|
#
|
@@ -26,6 +26,14 @@ module Kafka
|
|
26
26
|
@topics = topics
|
27
27
|
end
|
28
28
|
|
29
|
+
def each_partition
|
30
|
+
@topics.each do |topic_info|
|
31
|
+
topic_info.partitions.each do |partition_info|
|
32
|
+
yield topic_info, partition_info
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
29
37
|
def self.decode(decoder)
|
30
38
|
topics = decoder.array do
|
31
39
|
topic = decoder.string
|
data/lib/kafka/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby-kafka
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.0.pre.
|
4
|
+
version: 0.1.0.pre.beta2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Daniel Schierbeck
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-01-
|
11
|
+
date: 2016-01-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -52,6 +52,20 @@ dependencies:
|
|
52
52
|
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
55
69
|
description: A client library for the Kafka distributed commit log. Still very much
|
56
70
|
at the alpha stage.
|
57
71
|
email:
|
@@ -62,22 +76,20 @@ extra_rdoc_files: []
|
|
62
76
|
files:
|
63
77
|
- ".gitignore"
|
64
78
|
- ".rspec"
|
65
|
-
- ".travis.yml"
|
66
79
|
- Gemfile
|
67
80
|
- LICENSE.txt
|
68
81
|
- README.md
|
69
82
|
- Rakefile
|
70
83
|
- bin/console
|
71
84
|
- bin/setup
|
72
|
-
-
|
85
|
+
- circle.yml
|
73
86
|
- kafka.gemspec
|
74
87
|
- lib/kafka.rb
|
75
88
|
- lib/kafka/broker.rb
|
76
89
|
- lib/kafka/broker_pool.rb
|
77
90
|
- lib/kafka/client.rb
|
78
91
|
- lib/kafka/connection.rb
|
79
|
-
- lib/kafka/
|
80
|
-
- lib/kafka/message_set.rb
|
92
|
+
- lib/kafka/message_buffer.rb
|
81
93
|
- lib/kafka/partitioner.rb
|
82
94
|
- lib/kafka/producer.rb
|
83
95
|
- lib/kafka/protocol.rb
|
@@ -91,7 +103,6 @@ files:
|
|
91
103
|
- lib/kafka/protocol/topic_metadata_request.rb
|
92
104
|
- lib/kafka/version.rb
|
93
105
|
- lib/ruby-kafka.rb
|
94
|
-
- test-setup.sh
|
95
106
|
homepage: https://github.com/zendesk/ruby-kafka
|
96
107
|
licenses:
|
97
108
|
- Apache License Version 2.0
|
data/.travis.yml
DELETED
data/docker-compose.yml
DELETED
@@ -1,35 +0,0 @@
|
|
1
|
-
zookeeper:
|
2
|
-
image: wurstmeister/zookeeper
|
3
|
-
ports:
|
4
|
-
- "2181"
|
5
|
-
kafka1:
|
6
|
-
image: wurstmeister/kafka:0.8.2.0
|
7
|
-
ports:
|
8
|
-
- "9992:9092"
|
9
|
-
links:
|
10
|
-
- zookeeper:zk
|
11
|
-
environment:
|
12
|
-
KAFKA_ADVERTISED_HOST_NAME: "192.168.42.45"
|
13
|
-
KAFKA_CREATE_TOPICS: "test-messages:5:3"
|
14
|
-
volumes:
|
15
|
-
- /var/run/docker.sock:/var/run/docker.sock
|
16
|
-
kafka2:
|
17
|
-
image: wurstmeister/kafka:0.8.2.0
|
18
|
-
ports:
|
19
|
-
- "9993:9092"
|
20
|
-
links:
|
21
|
-
- zookeeper:zk
|
22
|
-
environment:
|
23
|
-
KAFKA_ADVERTISED_HOST_NAME: "192.168.42.45"
|
24
|
-
volumes:
|
25
|
-
- /var/run/docker.sock:/var/run/docker.sock
|
26
|
-
kafka3:
|
27
|
-
image: wurstmeister/kafka:0.8.2.0
|
28
|
-
ports:
|
29
|
-
- "9994:9092"
|
30
|
-
links:
|
31
|
-
- zookeeper:zk
|
32
|
-
environment:
|
33
|
-
KAFKA_ADVERTISED_HOST_NAME: "192.168.42.45"
|
34
|
-
volumes:
|
35
|
-
- /var/run/docker.sock:/var/run/docker.sock
|
data/lib/kafka/message.rb
DELETED
data/lib/kafka/message_set.rb
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
require "kafka/protocol/message"
|
2
|
-
|
3
|
-
module Kafka
|
4
|
-
class MessageSet
|
5
|
-
def initialize(messages)
|
6
|
-
@messages = messages
|
7
|
-
end
|
8
|
-
|
9
|
-
def to_h
|
10
|
-
hsh = {}
|
11
|
-
|
12
|
-
@messages.each do |message|
|
13
|
-
value, key = message.value, message.key
|
14
|
-
topic, partition = message.topic, message.partition
|
15
|
-
|
16
|
-
hsh[topic] ||= {}
|
17
|
-
hsh[topic][partition] ||= []
|
18
|
-
hsh[topic][partition] << Protocol::Message.new(value: value, key: key)
|
19
|
-
end
|
20
|
-
|
21
|
-
hsh
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
data/test-setup.sh
DELETED