racecar 1.3.0 → 2.0.0.alpha1

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.
@@ -0,0 +1,177 @@
1
+ module Racecar
2
+ class ConsumerSet
3
+ def initialize(config, logger)
4
+ @config, @logger = config, logger
5
+ raise ArgumentError, "Subscriptions must not be empty when subscribing" if @config.subscriptions.empty?
6
+
7
+ @consumers = []
8
+ @consumer_id_iterator = (0...@config.subscriptions.size).cycle
9
+
10
+ @last_poll_read_nil_message = false
11
+ end
12
+
13
+ def poll(timeout_ms)
14
+ maybe_select_next_consumer
15
+ retried ||= false
16
+ msg = current.poll(timeout_ms)
17
+ rescue Rdkafka::RdkafkaError => e
18
+ @logger.error "Error for topic subscription #{current_subscription}: #{e}"
19
+
20
+ case e.code
21
+ when :max_poll_exceeded
22
+ reset_current_consumer
23
+ raise if retried
24
+ retried = true
25
+ retry
26
+ else
27
+ raise
28
+ end
29
+ ensure
30
+ @last_poll_read_nil_message = true if msg.nil?
31
+ end
32
+
33
+ # XXX: messages are not guaranteed to be from the same partition
34
+ def batch_poll(timeout_ms)
35
+ @batch_started_at = Time.now
36
+ @messages = []
37
+ while collect_messages_for_batch? do
38
+ msg = poll(timeout_ms)
39
+ break if msg.nil?
40
+ @messages << msg
41
+ end
42
+ @messages
43
+ end
44
+
45
+ def store_offset(message)
46
+ current.store_offset(message)
47
+ end
48
+
49
+ def commit
50
+ each_subscribed do |consumer|
51
+ commit_rescue_no_offset(consumer)
52
+ end
53
+ end
54
+
55
+ def close
56
+ each_subscribed(&:close)
57
+ end
58
+
59
+ def current
60
+ @consumers[@consumer_id_iterator.peek] ||= begin
61
+ consumer = Rdkafka::Config.new(rdkafka_config(current_subscription)).consumer
62
+ consumer.subscribe current_subscription.topic
63
+ consumer
64
+ end
65
+ end
66
+
67
+ def each_subscribed
68
+ if block_given?
69
+ @consumers.each { |c| yield c }
70
+ else
71
+ @consumers.each
72
+ end
73
+ end
74
+
75
+ def pause(topic, partition, offset)
76
+ consumer, filtered_tpl = find_consumer_by(topic, partition)
77
+ if !consumer
78
+ @logger.info "Attempted to pause #{topic}/#{partition}, but we're not subscribed to it"
79
+ return
80
+ end
81
+
82
+ consumer.pause(filtered_tpl)
83
+ fake_msg = OpenStruct.new(topic: topic, partition: partition, offset: offset)
84
+ consumer.seek(fake_msg)
85
+ end
86
+
87
+ def resume(topic, partition)
88
+ consumer, filtered_tpl = find_consumer_by(topic, partition)
89
+ if !consumer
90
+ @logger.info "Attempted to resume #{topic}/#{partition}, but we're not subscribed to it"
91
+ return
92
+ end
93
+
94
+ consumer.resume(filtered_tpl)
95
+ end
96
+
97
+ alias :each :each_subscribed
98
+
99
+ # Subscribe to all topics eagerly, even if there's still messages elsewhere. Usually
100
+ # that's not needed and Kafka might rebalance if topics are not polled frequently
101
+ # enough.
102
+ def subscribe_all
103
+ @config.subscriptions.size.times do
104
+ current
105
+ select_next_consumer
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def find_consumer_by(topic, partition)
112
+ each do |consumer|
113
+ tpl = consumer.assignment.to_h
114
+ rdkafka_partition = tpl[topic]&.detect { |part| part.partition == partition }
115
+ next unless rdkafka_partition
116
+ filtered_tpl = Rdkafka::Consumer::TopicPartitionList.new({ topic => [rdkafka_partition] })
117
+ return consumer, filtered_tpl
118
+ end
119
+
120
+ return nil, nil
121
+ end
122
+
123
+ def current_subscription
124
+ @config.subscriptions[@consumer_id_iterator.peek]
125
+ end
126
+
127
+ def reset_current_consumer
128
+ @consumers[@consumer_id_iterator.peek] = nil
129
+ end
130
+
131
+ def maybe_select_next_consumer
132
+ return unless @last_poll_read_nil_message
133
+ @last_poll_read_nil_message = false
134
+ select_next_consumer
135
+ end
136
+
137
+ def select_next_consumer
138
+ @consumer_id_iterator.next
139
+ end
140
+
141
+ def commit_rescue_no_offset(consumer)
142
+ consumer.commit(nil, !@config.synchronous_commits)
143
+ rescue Rdkafka::RdkafkaError => e
144
+ raise e if e.code != :no_offset
145
+ @logger.debug "Nothing to commit."
146
+ end
147
+
148
+ def collect_messages_for_batch?
149
+ @messages.size < @config.fetch_messages &&
150
+ (Time.now - @batch_started_at) < @config.max_wait_time
151
+ end
152
+
153
+ def rdkafka_config(subscription)
154
+ # https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
155
+ config = {
156
+ "auto.commit.interval.ms" => @config.offset_commit_interval * 1000,
157
+ "auto.offset.reset" => subscription.start_from_beginning ? "earliest" : "largest",
158
+ "bootstrap.servers" => @config.brokers.join(","),
159
+ "client.id" => @config.client_id,
160
+ "enable.partition.eof" => false,
161
+ "fetch.max.bytes" => @config.max_bytes,
162
+ "fetch.message.max.bytes" => subscription.max_bytes_per_partition,
163
+ "fetch.wait.max.ms" => @config.max_wait_time * 1000,
164
+ "group.id" => @config.group_id,
165
+ "heartbeat.interval.ms" => @config.heartbeat_interval * 1000,
166
+ "max.poll.interval.ms" => @config.max_poll_interval * 1000,
167
+ "queued.min.messages" => @config.min_message_queue_size,
168
+ "session.timeout.ms" => @config.session_timeout * 1000,
169
+ "socket.timeout.ms" => @config.socket_timeout * 1000,
170
+ "statistics.interval.ms" => 1000, # 1s is the highest granularity offered
171
+ }
172
+ config.merge! @config.rdkafka_consumer
173
+ config.merge! subscription.additional_config
174
+ config
175
+ end
176
+ end
177
+ end
data/lib/racecar/ctl.rb CHANGED
@@ -93,15 +93,12 @@ module Racecar
93
93
 
94
94
  Racecar.config.validate!
95
95
 
96
- kafka = Kafka.new(
97
- client_id: Racecar.config.client_id,
98
- seed_brokers: Racecar.config.brokers,
99
- logger: Racecar.logger,
100
- connect_timeout: Racecar.config.connect_timeout,
101
- socket_timeout: Racecar.config.socket_timeout,
102
- )
103
-
104
- kafka.deliver_message(message.value, key: message.key, topic: message.topic)
96
+ producer = Rdkafka::Config.new({
97
+ "bootstrap.servers": Racecar.config.brokers.join(","),
98
+ "client.id": Racecar.config.client_id,
99
+ }.merge(Racecar.config.rdkafka_producer)).producer
100
+
101
+ producer.produce(payload: message.value, key: message.key, topic: message.topic).wait
105
102
 
106
103
  $stderr.puts "=> Delivered message to Kafka cluster"
107
104
  end
@@ -0,0 +1,55 @@
1
+ module Racecar
2
+ class Pause
3
+ def initialize(timeout: nil, max_timeout: nil, exponential_backoff: false)
4
+ @started_at = nil
5
+ @pauses = 0
6
+ @timeout = timeout
7
+ @max_timeout = max_timeout
8
+ @exponential_backoff = exponential_backoff
9
+ end
10
+
11
+ def pause!
12
+ @started_at = Time.now
13
+ @ends_at = @started_at + backoff_interval unless @timeout.nil?
14
+ @pauses += 1
15
+ end
16
+
17
+ def resume!
18
+ @started_at = nil
19
+ @ends_at = nil
20
+ end
21
+
22
+ def paused?
23
+ !@started_at.nil?
24
+ end
25
+
26
+ def pause_duration
27
+ if paused?
28
+ Time.now - @started_at
29
+ else
30
+ 0
31
+ end
32
+ end
33
+
34
+ def expired?
35
+ return false if @timeout.nil?
36
+ return true unless @ends_at
37
+ Time.now >= @ends_at
38
+ end
39
+
40
+ def reset!
41
+ @pauses = 0
42
+ end
43
+
44
+ def backoff_interval
45
+ return Float::INFINITY if @timeout.nil?
46
+
47
+ backoff_factor = @exponential_backoff ? 2**@pauses : 1
48
+ timeout = backoff_factor * @timeout
49
+
50
+ timeout = @max_timeout if @max_timeout && timeout > @max_timeout
51
+
52
+ timeout
53
+ end
54
+ end
55
+ end
@@ -1,152 +1,221 @@
1
- require "kafka"
1
+ require "rdkafka"
2
+ require "racecar/pause"
2
3
 
3
4
  module Racecar
4
5
  class Runner
5
- attr_reader :processor, :config, :logger, :consumer
6
+ attr_reader :processor, :config, :logger
6
7
 
7
8
  def initialize(processor, config:, logger:, instrumenter: NullInstrumenter)
8
9
  @processor, @config, @logger = processor, config, logger
9
10
  @instrumenter = instrumenter
11
+ @stop_requested = false
12
+ Rdkafka::Config.logger = logger
13
+
14
+ if processor.respond_to?(:statistics_callback)
15
+ Rdkafka::Config.statistics_callback = processor.method(:statistics_callback).to_proc
16
+ end
17
+
18
+ setup_pauses
10
19
  end
11
20
 
12
- def stop
13
- Thread.new do
14
- processor.teardown
15
- consumer.stop unless consumer.nil?
16
- end.join
21
+ def setup_pauses
22
+ timeout = if config.pause_timeout == -1
23
+ nil
24
+ elsif config.pause_timeout == 0
25
+ # no op, handled elsewhere
26
+ elsif config.pause_timeout > 0
27
+ config.pause_timeout
28
+ else
29
+ raise ArgumentError, "Invalid value for pause_timeout: must be integer greater or equal -1"
30
+ end
31
+
32
+ @pauses = Hash.new {|h, k|
33
+ h[k] = Hash.new {|h2, k2|
34
+ h2[k2] = ::Racecar::Pause.new(
35
+ timeout: timeout,
36
+ max_timeout: config.max_pause_timeout,
37
+ exponential_backoff: config.pause_with_exponential_backoff
38
+ )
39
+ }
40
+ }
17
41
  end
18
42
 
19
43
  def run
20
- kafka = Kafka.new(
21
- client_id: config.client_id,
22
- seed_brokers: config.brokers,
23
- logger: logger,
24
- connect_timeout: config.connect_timeout,
25
- socket_timeout: config.socket_timeout,
26
- ssl_ca_cert: config.ssl_ca_cert,
27
- ssl_ca_cert_file_path: config.ssl_ca_cert_file_path,
28
- ssl_client_cert: config.ssl_client_cert,
29
- ssl_client_cert_key: config.ssl_client_cert_key,
30
- ssl_client_cert_key_password: config.ssl_client_cert_key_password,
31
- sasl_plain_username: config.sasl_plain_username,
32
- sasl_plain_password: config.sasl_plain_password,
33
- sasl_scram_username: config.sasl_scram_username,
34
- sasl_scram_password: config.sasl_scram_password,
35
- sasl_scram_mechanism: config.sasl_scram_mechanism,
36
- sasl_oauth_token_provider: config.sasl_oauth_token_provider,
37
- sasl_over_ssl: config.sasl_over_ssl,
38
- ssl_ca_certs_from_system: config.ssl_ca_certs_from_system,
39
- ssl_verify_hostname: config.ssl_verify_hostname,
40
- partitioner: Kafka::Partitioner.new(hash_function: config.partitioner)
44
+ install_signal_handlers
45
+ @stop_requested = false
46
+
47
+ # Configure the consumer with a producer so it can produce messages and
48
+ # with a consumer so that it can support advanced use-cases.
49
+ processor.configure(
50
+ producer: producer,
51
+ consumer: consumer,
52
+ instrumenter: @instrumenter,
41
53
  )
42
54
 
43
- @consumer = kafka.consumer(
44
- group_id: config.group_id,
45
- offset_commit_interval: config.offset_commit_interval,
46
- offset_commit_threshold: config.offset_commit_threshold,
47
- session_timeout: config.session_timeout,
48
- heartbeat_interval: config.heartbeat_interval,
49
- offset_retention_time: config.offset_retention_time,
50
- fetcher_max_queue_size: config.max_fetch_queue_size,
51
- )
55
+ instrument_payload = { consumer_class: processor.class.to_s, consumer_set: consumer }
56
+
57
+ # Main loop
58
+ loop do
59
+ break if @stop_requested
60
+ resume_paused_partitions
61
+ @instrumenter.instrument("main_loop.racecar", instrument_payload) do
62
+ case process_method
63
+ when :batch then
64
+ msg_per_part = consumer.batch_poll(config.max_wait_time).group_by(&:partition)
65
+ msg_per_part.each_value do |messages|
66
+ process_batch(messages)
67
+ end
68
+ when :single then
69
+ message = consumer.poll(config.max_wait_time)
70
+ process(message) if message
71
+ end
72
+ end
73
+ end
74
+
75
+ logger.info "Gracefully shutting down"
76
+ processor.deliver!
77
+ processor.teardown
78
+ consumer.commit
79
+ consumer.close
80
+ end
52
81
 
82
+ def stop
83
+ @stop_requested = true
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :pauses
89
+
90
+ def process_method
91
+ @process_method ||= begin
92
+ case
93
+ when processor.respond_to?(:process_batch) then :batch
94
+ when processor.respond_to?(:process) then :single
95
+ else
96
+ raise NotImplementedError, "Consumer class must implement process or process_batch method"
97
+ end
98
+ end
99
+ end
100
+
101
+ def consumer
102
+ @consumer ||= begin
103
+ # Manually store offset after messages have been processed successfully
104
+ # to avoid marking failed messages as committed. The call just updates
105
+ # a value within librdkafka and is asynchronously written to proper
106
+ # storage through auto commits.
107
+ config.consumer << "enable.auto.offset.store=false"
108
+ ConsumerSet.new(config, logger)
109
+ end
110
+ end
111
+
112
+ def producer
113
+ @producer ||= Rdkafka::Config.new(producer_config).producer.tap do |producer|
114
+ producer.delivery_callback = delivery_callback
115
+ end
116
+ end
117
+
118
+ def producer_config
119
+ # https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
120
+ producer_config = {
121
+ "bootstrap.servers" => config.brokers.join(","),
122
+ "client.id" => config.client_id,
123
+ "statistics.interval.ms" => 1000,
124
+ }
125
+ producer_config["compression.codec"] = config.producer_compression_codec.to_s unless config.producer_compression_codec.nil?
126
+ producer_config.merge!(config.rdkafka_producer)
127
+ producer_config
128
+ end
129
+
130
+ def delivery_callback
131
+ ->(delivery_report) do
132
+ data = {offset: delivery_report.offset, partition: delivery_report.partition}
133
+ @instrumenter.instrument("acknowledged_message.racecar", data)
134
+ end
135
+ end
136
+
137
+ def install_signal_handlers
53
138
  # Stop the consumer on SIGINT, SIGQUIT or SIGTERM.
54
139
  trap("QUIT") { stop }
55
- trap("INT") { stop }
140
+ trap("INT") { stop }
56
141
  trap("TERM") { stop }
57
142
 
58
143
  # Print the consumer config to STDERR on USR1.
59
144
  trap("USR1") { $stderr.puts config.inspect }
145
+ end
60
146
 
61
- config.subscriptions.each do |subscription|
62
- consumer.subscribe(
63
- subscription.topic,
64
- start_from_beginning: subscription.start_from_beginning,
65
- max_bytes_per_partition: subscription.max_bytes_per_partition,
66
- )
147
+ def process(message)
148
+ payload = {
149
+ consumer_class: processor.class.to_s,
150
+ topic: message.topic,
151
+ partition: message.partition,
152
+ offset: message.offset,
153
+ }
154
+
155
+ @instrumenter.instrument("process_message.racecar", payload) do
156
+ with_pause(message.topic, message.partition, message.offset..message.offset) do
157
+ processor.process(message)
158
+ processor.deliver!
159
+ consumer.store_offset(message)
160
+ end
67
161
  end
162
+ end
68
163
 
69
- # Configure the consumer with a producer so it can produce messages.
70
- producer = kafka.producer(
71
- compression_codec: config.producer_compression_codec,
72
- )
164
+ def process_batch(messages)
165
+ payload = {
166
+ consumer_class: processor.class.to_s,
167
+ topic: messages.first.topic,
168
+ partition: messages.first.partition,
169
+ first_offset: messages.first.offset,
170
+ message_count: messages.size,
171
+ }
172
+
173
+ @instrumenter.instrument("process_batch.racecar", payload) do
174
+ first, last = messages.first, messages.last
175
+ with_pause(first.topic, first.partition, first.offset..last.offset) do
176
+ processor.process_batch(messages)
177
+ processor.deliver!
178
+ consumer.store_offset(messages.last)
179
+ end
180
+ end
181
+ end
73
182
 
74
- processor.configure(consumer: consumer, producer: producer)
183
+ def with_pause(topic, partition, offsets)
184
+ return yield if config.pause_timeout == 0
75
185
 
76
186
  begin
77
- if processor.respond_to?(:process)
78
- consumer.each_message(max_wait_time: config.max_wait_time, max_bytes: config.max_bytes) do |message|
79
- payload = {
80
- consumer_class: processor.class.to_s,
81
- topic: message.topic,
82
- partition: message.partition,
83
- offset: message.offset,
84
- }
85
-
86
- @instrumenter.instrument("process_message.racecar", payload) do
87
- processor.process(message)
88
- producer.deliver_messages
89
- end
90
- end
91
- elsif processor.respond_to?(:process_batch)
92
- consumer.each_batch(max_wait_time: config.max_wait_time, max_bytes: config.max_bytes) do |batch|
93
- payload = {
94
- consumer_class: processor.class.to_s,
95
- topic: batch.topic,
96
- partition: batch.partition,
97
- first_offset: batch.first_offset,
98
- message_count: batch.messages.count,
99
- }
100
-
101
- @instrumenter.instrument("process_batch.racecar", payload) do
102
- processor.process_batch(batch)
103
- producer.deliver_messages
104
- end
187
+ yield
188
+ # We've successfully processed a batch from the partition, so we can clear the pause.
189
+ pauses[topic][partition].reset!
190
+ rescue => e
191
+ desc = "#{topic}/#{partition}"
192
+ logger.error "Failed to process #{desc} at #{offsets}: #{e}"
193
+
194
+ pause = pauses[topic][partition]
195
+ logger.warn "Pausing partition #{desc} for #{pause.backoff_interval} seconds"
196
+ consumer.pause(topic, partition, offsets.first)
197
+ pause.pause!
198
+ end
199
+ end
200
+
201
+ def resume_paused_partitions
202
+ return if config.pause_timeout == 0
203
+
204
+ pauses.each do |topic, partitions|
205
+ partitions.each do |partition, pause|
206
+ @instrumenter.instrument("pause_status.racecar", {
207
+ topic: topic,
208
+ partition: partition,
209
+ duration: pause.pause_duration,
210
+ })
211
+
212
+ if pause.paused? && pause.expired?
213
+ logger.info "Automatically resuming partition #{topic}/#{partition}, pause timeout expired"
214
+ consumer.resume(topic, partition)
215
+ pause.resume!
216
+ # TODO: # During re-balancing we might have lost the paused partition. Check if partition is still in group before seek. ?
105
217
  end
106
- else
107
- raise NotImplementedError, "Consumer class must implement process or process_batch method"
108
218
  end
109
- rescue Kafka::ProcessingError => e
110
- @logger.error "Error processing partition #{e.topic}/#{e.partition} at offset #{e.offset}"
111
-
112
- if config.pause_timeout > 0
113
- # Pause fetches from the partition. We'll continue processing the other partitions in the topic.
114
- # The partition is automatically resumed after the specified timeout, and will continue where we
115
- # left off.
116
- @logger.warn "Pausing partition #{e.topic}/#{e.partition} for #{config.pause_timeout} seconds"
117
- consumer.pause(
118
- e.topic,
119
- e.partition,
120
- timeout: config.pause_timeout,
121
- max_timeout: config.max_pause_timeout,
122
- exponential_backoff: config.pause_with_exponential_backoff?,
123
- )
124
- elsif config.pause_timeout == -1
125
- # A pause timeout of -1 means indefinite pausing, which in ruby-kafka is done by passing nil as
126
- # the timeout.
127
- @logger.warn "Pausing partition #{e.topic}/#{e.partition} indefinitely, or until the process is restarted"
128
- consumer.pause(e.topic, e.partition, timeout: nil)
129
- end
130
-
131
- config.error_handler.call(e.cause, {
132
- topic: e.topic,
133
- partition: e.partition,
134
- offset: e.offset,
135
- })
136
-
137
- # Restart the consumer loop.
138
- retry
139
- rescue Kafka::InvalidSessionTimeout
140
- raise ConfigError, "`session_timeout` is set either too high or too low"
141
- rescue Kafka::Error => e
142
- error = "#{e.class}: #{e.message}\n" + e.backtrace.join("\n")
143
- @logger.error "Consumer thread crashed: #{error}"
144
-
145
- config.error_handler.call(e)
146
-
147
- raise
148
- else
149
- @logger.info "Gracefully shutting down"
150
219
  end
151
220
  end
152
221
  end
@@ -1,3 +1,3 @@
1
1
  module Racecar
2
- VERSION = "1.3.0"
2
+ VERSION = "2.0.0.alpha1"
3
3
  end
data/lib/racecar.rb CHANGED
@@ -1,14 +1,16 @@
1
1
  require "logger"
2
2
 
3
3
  require "racecar/consumer"
4
+ require "racecar/consumer_set"
4
5
  require "racecar/runner"
5
6
  require "racecar/config"
7
+ require "ensure_hash_compact"
6
8
 
7
9
  module Racecar
8
10
  # Ignores all instrumentation events.
9
11
  class NullInstrumenter
10
12
  def self.instrument(*)
11
- yield if block_given?
13
+ yield({}) if block_given?
12
14
  end
13
15
  end
14
16
 
@@ -22,10 +24,6 @@ module Racecar
22
24
  @config ||= Config.new
23
25
  end
24
26
 
25
- def self.config=(config)
26
- @config = config
27
- end
28
-
29
27
  def self.configure
30
28
  yield config
31
29
  end
data/racecar.gemspec CHANGED
@@ -20,10 +20,11 @@ Gem::Specification.new do |spec|
20
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
21
  spec.require_paths = ["lib"]
22
22
 
23
- spec.add_runtime_dependency "king_konf", "~> 1.0"
24
- spec.add_runtime_dependency "ruby-kafka", "~> 1.0"
23
+ spec.add_runtime_dependency "king_konf", "~> 0.3.7"
24
+ spec.add_runtime_dependency "rdkafka", "~> 0.6.0"
25
25
 
26
26
  spec.add_development_dependency "bundler", [">= 1.13", "< 3"]
27
- spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "rake", "> 10.0"
28
28
  spec.add_development_dependency "rspec", "~> 3.0"
29
+ spec.add_development_dependency "timecop"
29
30
  end