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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: eb0de1c04f8551ffe3750b3eac702b51777e525d
4
- data.tar.gz: 1e6ed1b055a754d7eb958a03314f50f62138de59
3
+ metadata.gz: 6d8c3294bd19a1c201194ff051f6d98d7a6dc273
4
+ data.tar.gz: 1769607ad012c3913b1ad707922ec271cc7a4567
5
5
  SHA512:
6
- metadata.gz: 5ac63915c1bead98581a2b4ddd577887ec5fa7f8d7c14ebcedac3d77e06d24a718a1df3b88961edd13927acdb16e4992acdaaecd2e05dc7fe1e20c511a8ad6c1
7
- data.tar.gz: 2273d4da86a6ee82c5c3cc310e1549056638e1c07839abcbbb511f9dc8cb055ed8680e4ee9b5415bbb00b21155c4955f66d7be385423e639c5c27e0e2b38f8c8
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-alpha 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.
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
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
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
@@ -3,5 +3,3 @@ set -euo pipefail
3
3
  IFS=$'\n\t'
4
4
 
5
5
  bundle install
6
-
7
- # Do any other automated setup that you need to do here
data/circle.yml ADDED
@@ -0,0 +1,3 @@
1
+ dependencies:
2
+ pre:
3
+ - gem install bundler
data/kafka.gemspec CHANGED
@@ -22,4 +22,5 @@ Gem::Specification.new do |spec|
22
22
  spec.add_development_dependency "bundler", "~> 1.10"
23
23
  spec.add_development_dependency "rake", "~> 10.0"
24
24
  spec.add_development_dependency "rspec"
25
+ spec.add_development_dependency "pry"
25
26
  end
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 = Class.new(StandardError)
6
- ConnectionError = Class.new(Error)
7
- CorruptMessage = Class.new(Error)
8
- UnknownError = Class.new(Error)
9
- OffsetOutOfRange = Class.new(Error)
10
- UnknownTopicOrPartition = Class.new(Error)
11
- InvalidMessageSize = Class.new(Error)
12
- LeaderNotAvailable = Class.new(Error)
13
- NotLeaderForPartition = Class.new(Error)
14
- RequestTimedOut = Class.new(Error)
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 = Class.new(Error)
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"
@@ -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
- refresh
27
+ def mark_as_stale!
28
+ @cluster_info = nil
32
29
  end
33
30
 
34
- # Refreshes the cluster metadata.
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 `seed_brokers`, connecting to the
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 `seed_brokers` are available.
41
- # @return [nil]
42
- def refresh
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
- @cluster_info = broker.fetch_metadata
84
+ cluster_info = broker.fetch_metadata
58
85
 
59
- @logger.info "Initialized broker pool with brokers: #{@cluster_info.brokers.inspect}"
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 broker #{broker}: #{e}"
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 = @cluster_info.find_broker(broker_id)
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
@@ -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 `response_class`.
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
- read_response(response_class)
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
@@ -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
- Zlib.crc32(key) % @partitions.count
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
@@ -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
- # @param timeout [Integer] The number of seconds to wait for an
8
- # acknowledgement from the broker before timing out.
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
- def initialize(broker_pool:, logger:, timeout: 10, required_acks: 1)
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
- @buffered_messages = []
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. The simplest option is to not specify a partition or partition
26
- # key, in which case the message key will be used to select one of the available
27
- # partitions. You can also specify the `partition` parameter yourself. This
28
- # requires you to know which partitions are available, however. Oftentimes the
29
- # best option is to specify the `partition_key` parameter: messages with the
30
- # same partition key will always be assigned to the same partition, as long as
31
- # the number of partitions doesn't change.
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
- # @return [Message] the message that was written.
40
- def write(value, key:, topic:, partition: nil, partition_key: nil)
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(value, key: key, topic: topic, partition: partition)
109
+ message = Protocol::Message.new(key: key, value: value)
49
110
 
50
- @buffered_messages << message
111
+ @buffer.write(message, topic: topic, partition: partition)
51
112
 
52
- message
113
+ partition
53
114
  end
54
115
 
55
116
  # Flushes all messages to the Kafka brokers.
56
117
  #
57
- # Depending on the value of `required_acks` used when initializing the producer,
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 `timeout` setting places an upper bound on the amount of time
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
- @buffered_messages.each do |message|
67
- broker = @broker_pool.get_leader(message.topic, message.partition)
178
+ @buffer.each do |topic, partition, messages|
179
+ broker_id = @broker_pool.get_leader_id(topic, partition)
68
180
 
69
- messages_for_broker[broker] ||= []
70
- messages_for_broker[broker] << message
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 |broker, messages|
74
- @logger.info "Sending #{messages.count} messages to broker #{broker}"
187
+ messages_for_broker.each do |broker_id, message_set|
188
+ begin
189
+ broker = @broker_pool.get_broker(broker_id)
75
190
 
76
- message_set = MessageSet.new(messages)
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
- response = broker.produce(
79
- messages_for_topics: message_set.to_h,
80
- required_acks: @required_acks,
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
- if response
85
- response.topics.each do |topic_info|
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
- @buffered_messages.clear
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
- nil
96
- end
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
- def shutdown
99
- @broker_pool.shutdown
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
@@ -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
- case error_code
8
- when -1 then raise UnknownError
9
- when 0 then nil # no error, yay!
10
- when 1 then raise OffsetOutOfRange
11
- when 2 then raise CorruptMessage
12
- when 3 then raise UnknownTopicOrPartition
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 `isr` list is
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
@@ -1,3 +1,3 @@
1
1
  module Kafka
2
- VERSION = "0.1.0-beta1"
2
+ VERSION = "0.1.0-beta2"
3
3
  end
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.beta1
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-25 00:00:00.000000000 Z
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
- - docker-compose.yml
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/message.rb
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
@@ -1,4 +0,0 @@
1
- language: ruby
2
- rvm:
3
- - 2.2.3
4
- before_install: gem install bundler -v 1.10.6
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
@@ -1,12 +0,0 @@
1
- module Kafka
2
- class Message
3
- attr_reader :value, :key, :topic, :partition
4
-
5
- def initialize(value, key:, topic:, partition:)
6
- @value = value
7
- @key = key
8
- @topic = topic
9
- @partition = partition
10
- end
11
- end
12
- end
@@ -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
@@ -1,3 +0,0 @@
1
- #!/bin/bash
2
-
3
- docker-compose run kafka1 /opt/kafka_2.10-0.8.2.0/bin/kafka-topics.sh --create --topic test-messages --replication-factor 3 --partitions 5 --zookeeper zk