chore-core 1.5.2
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.
- checksums.yaml +15 -0
- data/LICENSE.txt +20 -0
- data/README.md +260 -0
- data/Rakefile +32 -0
- data/bin/chore +34 -0
- data/chore-core.gemspec +46 -0
- data/lib/chore/cli.rb +232 -0
- data/lib/chore/configuration.rb +13 -0
- data/lib/chore/consumer.rb +52 -0
- data/lib/chore/duplicate_detector.rb +56 -0
- data/lib/chore/fetcher.rb +31 -0
- data/lib/chore/hooks.rb +25 -0
- data/lib/chore/job.rb +103 -0
- data/lib/chore/json_encoder.rb +18 -0
- data/lib/chore/manager.rb +47 -0
- data/lib/chore/publisher.rb +29 -0
- data/lib/chore/queues/filesystem/consumer.rb +128 -0
- data/lib/chore/queues/filesystem/filesystem_queue.rb +49 -0
- data/lib/chore/queues/filesystem/publisher.rb +45 -0
- data/lib/chore/queues/sqs/consumer.rb +121 -0
- data/lib/chore/queues/sqs/publisher.rb +55 -0
- data/lib/chore/queues/sqs.rb +38 -0
- data/lib/chore/railtie.rb +18 -0
- data/lib/chore/signal.rb +175 -0
- data/lib/chore/strategies/consumer/batcher.rb +76 -0
- data/lib/chore/strategies/consumer/single_consumer_strategy.rb +34 -0
- data/lib/chore/strategies/consumer/threaded_consumer_strategy.rb +81 -0
- data/lib/chore/strategies/worker/forked_worker_strategy.rb +221 -0
- data/lib/chore/strategies/worker/single_worker_strategy.rb +39 -0
- data/lib/chore/tasks/queues.task +11 -0
- data/lib/chore/unit_of_work.rb +17 -0
- data/lib/chore/util.rb +18 -0
- data/lib/chore/version.rb +9 -0
- data/lib/chore/worker.rb +117 -0
- data/lib/chore-core.rb +1 -0
- data/lib/chore.rb +218 -0
- data/spec/chore/cli_spec.rb +182 -0
- data/spec/chore/consumer_spec.rb +36 -0
- data/spec/chore/duplicate_detector_spec.rb +62 -0
- data/spec/chore/fetcher_spec.rb +38 -0
- data/spec/chore/hooks_spec.rb +44 -0
- data/spec/chore/job_spec.rb +80 -0
- data/spec/chore/json_encoder_spec.rb +11 -0
- data/spec/chore/manager_spec.rb +39 -0
- data/spec/chore/queues/filesystem/filesystem_consumer_spec.rb +71 -0
- data/spec/chore/queues/sqs/consumer_spec.rb +136 -0
- data/spec/chore/queues/sqs/publisher_spec.rb +74 -0
- data/spec/chore/queues/sqs_spec.rb +37 -0
- data/spec/chore/signal_spec.rb +244 -0
- data/spec/chore/strategies/consumer/batcher_spec.rb +93 -0
- data/spec/chore/strategies/consumer/single_consumer_strategy_spec.rb +23 -0
- data/spec/chore/strategies/consumer/threaded_consumer_strategy_spec.rb +105 -0
- data/spec/chore/strategies/worker/forked_worker_strategy_spec.rb +281 -0
- data/spec/chore/strategies/worker/single_worker_strategy_spec.rb +36 -0
- data/spec/chore/worker_spec.rb +134 -0
- data/spec/chore_spec.rb +108 -0
- data/spec/spec_helper.rb +58 -0
- data/spec/test_job.rb +7 -0
- metadata +194 -0
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'aws/sqs'
|
2
|
+
require 'chore/duplicate_detector'
|
3
|
+
|
4
|
+
AWS.eager_autoload! AWS::Core
|
5
|
+
AWS.eager_autoload! AWS::SQS
|
6
|
+
|
7
|
+
module Chore
|
8
|
+
module Queues
|
9
|
+
module SQS
|
10
|
+
# SQS Consumer for Chore. Requests messages from SQS and passes them to be worked on. Also controls
|
11
|
+
# deleting completed messages within SQS.
|
12
|
+
class Consumer < Chore::Consumer
|
13
|
+
# Initialize the reset at on class load
|
14
|
+
@@reset_at = Time.now
|
15
|
+
|
16
|
+
Chore::CLI.register_option 'aws_access_key', '--aws-access-key KEY', 'Valid AWS Access Key'
|
17
|
+
Chore::CLI.register_option 'aws_secret_key', '--aws-secret-key KEY', 'Valid AWS Secret Key'
|
18
|
+
Chore::CLI.register_option 'dedupe_servers', '--dedupe-servers SERVERS', 'List of mememcache compatible server(s) to use for storing SQS Message Dedupe cache'
|
19
|
+
Chore::CLI.register_option 'queue_polling_size', '--queue_polling_size NUM', Integer, 'Amount of messages to grab on each request' do |arg|
|
20
|
+
raise ArgumentError, "Cannot specify a queue polling size greater than 10" if arg > 10
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(queue_name, opts={})
|
24
|
+
super(queue_name, opts)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Sets a flag that instructs the publisher to reset the connection the next time it's used
|
28
|
+
def self.reset_connection!
|
29
|
+
@@reset_at = Time.now
|
30
|
+
end
|
31
|
+
|
32
|
+
# Begins requesting messages from SQS, which will invoke the +&handler+ over each message
|
33
|
+
def consume(&handler)
|
34
|
+
while running?
|
35
|
+
begin
|
36
|
+
messages = handle_messages(&handler)
|
37
|
+
sleep 1 if messages.empty?
|
38
|
+
rescue AWS::SQS::Errors::NonExistentQueue => e
|
39
|
+
Chore.logger.error "You specified a queue that does not exist. You must create the queue before starting Chore. Shutting down..."
|
40
|
+
raise Chore::TerribleMistake
|
41
|
+
rescue => e
|
42
|
+
Chore.logger.error { "SQSConsumer#Consume: #{e.inspect} #{e.backtrace * "\n"}" }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Rejects the given message from SQS by +id+. Currently a noop
|
48
|
+
def reject(id)
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
# Deletes the given message from SQS by +id+
|
53
|
+
def complete(id)
|
54
|
+
Chore.logger.debug "Completing (deleting): #{id}"
|
55
|
+
queue.batch_delete([id])
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# Requests messages from SQS, and invokes the provided +&block+ over each one. Afterwards, the :on_fetch
|
61
|
+
# hook will be invoked, per message
|
62
|
+
def handle_messages(&block)
|
63
|
+
msg = queue.receive_messages(:limit => sqs_polling_amount, :attributes => [:receive_count])
|
64
|
+
|
65
|
+
messages = *msg
|
66
|
+
messages.each do |message|
|
67
|
+
block.call(message.handle, queue_name, queue_timeout, message.body, message.receive_count - 1) unless duplicate_message?(message)
|
68
|
+
Chore.run_hooks_for(:on_fetch, message.handle, message.body)
|
69
|
+
end
|
70
|
+
messages
|
71
|
+
end
|
72
|
+
|
73
|
+
# Checks if the given message has already been received within the timeout window for this queue
|
74
|
+
def duplicate_message?(message)
|
75
|
+
dupe_detector.found_duplicate?(message)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns the instance of the DuplicateDetector used to ensure unique messages.
|
79
|
+
# Will create one if one doesn't already exist
|
80
|
+
def dupe_detector
|
81
|
+
@dupes ||= DuplicateDetector.new({:servers => Chore.config.dedupe_servers,
|
82
|
+
:dupe_on_cache_failure => Chore.config.dupe_on_cache_failure})
|
83
|
+
end
|
84
|
+
|
85
|
+
# Retrieves the SQS queue with the given +name+. The method will cache the results to prevent round trips on
|
86
|
+
# subsequent calls. If <tt>reset_connection!</tt> has been called, this will result in the connection being
|
87
|
+
# re-initialized, as well as clear any cached results from prior calls
|
88
|
+
def queue
|
89
|
+
if !@sqs_last_connected || (@@reset_at && @@reset_at >= @sqs_last_connected)
|
90
|
+
AWS::Core::Http::ConnectionPool.pools.each do |p|
|
91
|
+
p.empty!
|
92
|
+
end
|
93
|
+
@sqs = nil
|
94
|
+
@sqs_last_connected = Time.now
|
95
|
+
@queue = nil
|
96
|
+
end
|
97
|
+
@queue_url ||= sqs.queues.url_for(@queue_name)
|
98
|
+
@queue ||= sqs.queues[@queue_url]
|
99
|
+
end
|
100
|
+
|
101
|
+
# The visibility timeout of the queue for this consumer
|
102
|
+
def queue_timeout
|
103
|
+
@queue_timeout ||= queue.visibility_timeout
|
104
|
+
end
|
105
|
+
|
106
|
+
# Access to the configured SQS connection object
|
107
|
+
def sqs
|
108
|
+
@sqs ||= AWS::SQS.new(
|
109
|
+
:access_key_id => Chore.config.aws_access_key,
|
110
|
+
:secret_access_key => Chore.config.aws_secret_key,
|
111
|
+
:logger => Chore.logger,
|
112
|
+
:log_level => :debug)
|
113
|
+
end
|
114
|
+
|
115
|
+
def sqs_polling_amount
|
116
|
+
Chore.config.queue_polling_size || 10
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'chore/publisher'
|
2
|
+
|
3
|
+
module Chore
|
4
|
+
module Queues
|
5
|
+
module SQS
|
6
|
+
|
7
|
+
# SQS Publisher, for writing messages to SQS from Chore
|
8
|
+
class Publisher < Chore::Publisher
|
9
|
+
@@reset_next = true
|
10
|
+
|
11
|
+
def initialize(opts={})
|
12
|
+
super
|
13
|
+
@sqs_queues = {}
|
14
|
+
@sqs_queue_urls = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Takes a given Chore::Job instance +job+, and publishes it by looking up the +queue_name+.
|
18
|
+
def publish(queue_name,job)
|
19
|
+
queue = self.queue(queue_name)
|
20
|
+
queue.send_message(encode_job(job))
|
21
|
+
end
|
22
|
+
|
23
|
+
# Sets a flag that instructs the publisher to reset the connection the next time it's used
|
24
|
+
def self.reset_connection!
|
25
|
+
@@reset_next = true
|
26
|
+
end
|
27
|
+
|
28
|
+
# Access to the configured SQS connection object
|
29
|
+
def sqs
|
30
|
+
@sqs ||= AWS::SQS.new(
|
31
|
+
:access_key_id => Chore.config.aws_access_key,
|
32
|
+
:secret_access_key => Chore.config.aws_secret_key,
|
33
|
+
:logger => Chore.logger,
|
34
|
+
:log_level => :debug)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Retrieves the SQS queue with the given +name+. The method will cache the results to prevent round trips on subsequent calls
|
38
|
+
# If <tt>reset_connection!</tt> has been called, this will result in the connection being re-initialized,
|
39
|
+
# as well as clear any cached results from prior calls
|
40
|
+
def queue(name)
|
41
|
+
if @@reset_next
|
42
|
+
AWS::Core::Http::ConnectionPool.pools.each do |p|
|
43
|
+
p.empty!
|
44
|
+
end
|
45
|
+
@sqs = nil
|
46
|
+
@@reset_next = false
|
47
|
+
@sqs_queues = {}
|
48
|
+
end
|
49
|
+
@sqs_queue_urls[name] ||= self.sqs.queues.url_for(name)
|
50
|
+
@sqs_queues[name] ||= self.sqs.queues[@sqs_queue_urls[name]]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Chore
|
2
|
+
module Queues
|
3
|
+
module SQS
|
4
|
+
# Helper method to create queues based on the currently known list as provided by your configured Chore::Jobs
|
5
|
+
# This is meant to be invoked from a rake task, and not directly.
|
6
|
+
# These queues will be created with the default settings, which may not be ideal.
|
7
|
+
# This is meant only as a convenience helper for testing, and not as a way to create production quality queues in SQS
|
8
|
+
def self.create_queues!
|
9
|
+
raise 'You must have atleast one Chore Job configured and loaded before attempting to create queues' unless Chore.prefixed_queue_names.length > 0
|
10
|
+
#This will raise an exception if AWS has not been configured by the project making use of Chore
|
11
|
+
sqs_queues = AWS::SQS.new.queues
|
12
|
+
Chore.prefixed_queue_names.each do |queue_name|
|
13
|
+
Chore.logger.info "Chore Creating Queue: #{queue_name}"
|
14
|
+
begin
|
15
|
+
sqs_queues.create(queue_name)
|
16
|
+
rescue AWS::SQS::Errors::QueueAlreadyExists
|
17
|
+
Chore.logger.info "exists with different config"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
Chore.prefixed_queue_names
|
21
|
+
end
|
22
|
+
|
23
|
+
# Helper method to delete all known queues based on the list as provided by your configured Chore::Jobs
|
24
|
+
# This is meant to be invoked from a rake task, and not directly.
|
25
|
+
def self.delete_queues!
|
26
|
+
raise 'You must have atleast one Chore Job configured and loaded before attempting to create queues' unless Chore.prefixed_queue_names.length > 0
|
27
|
+
#This will raise an exception if AWS has not been configured by the project making use of Chore
|
28
|
+
sqs_queues = AWS::SQS.new.queues
|
29
|
+
Chore.prefixed_queue_names.each do |queue_name|
|
30
|
+
Chore.logger.info "Chore Deleting Queue: #{queue_name}"
|
31
|
+
url = sqs_queues.url_for(queue_name)
|
32
|
+
sqs_queues[url].delete
|
33
|
+
end
|
34
|
+
Chore.prefixed_queue_names
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Chore
|
2
|
+
# Railtie for incorporating chores rake tasks into your Rails application
|
3
|
+
class Railtie < Rails::Railtie
|
4
|
+
rake_tasks do
|
5
|
+
Dir[File.join(File.dirname(__FILE__),'tasks/*.task')].each { |f| load f }
|
6
|
+
end
|
7
|
+
|
8
|
+
config.after_initialize do
|
9
|
+
if Chore.configuring?
|
10
|
+
# Reset the logger on forks to avoid deadlocks
|
11
|
+
Rails.logger = Chore.logger
|
12
|
+
Chore.add_hook(:after_fork) do |worker|
|
13
|
+
Rails.logger = Chore.logger
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/chore/signal.rb
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
module Chore
|
2
|
+
# Provides smarter signal handling capabilities than Ruby's built-in
|
3
|
+
# Signal class. Specifically it runs callbacks in a separate thread since:
|
4
|
+
# (1) Ruby 2.0 cannot obtain locks in the main Signal thread
|
5
|
+
# (2) Doing so can result in deadlocks in Ruby 1.9.x.
|
6
|
+
#
|
7
|
+
# Ruby's core implementation can be found at: http://ruby-doc.org/core-1.9.3/Signal.html
|
8
|
+
#
|
9
|
+
# == Differences
|
10
|
+
#
|
11
|
+
# There are a few important differences with the way signals trapped through
|
12
|
+
# this class behave than through Ruby's Signal class.
|
13
|
+
#
|
14
|
+
# === Sequential processing
|
15
|
+
#
|
16
|
+
# In Ruby, signals are interrupt-driven -- the thread is executing at the time
|
17
|
+
# will be interrupted at that point in the call stack and start executing the
|
18
|
+
# signal handler. This increases the potential for deadlocks if mutexes are
|
19
|
+
# in use by both the thread and the signal handler.
|
20
|
+
#
|
21
|
+
# In Chore, signal handlers are executed sequentially. When a handler is
|
22
|
+
# started, it must complete before the next signal is processed. These
|
23
|
+
# handlers are also executed in their own thread and, therefore, will compete
|
24
|
+
# for resources with the rest of the application.
|
25
|
+
#
|
26
|
+
# === Forking
|
27
|
+
#
|
28
|
+
# In Ruby, forking does not disrupt the ability to process signals. Signals
|
29
|
+
# trapped in the master process will continue to be trapped in forked child
|
30
|
+
# processes.
|
31
|
+
#
|
32
|
+
# In Chore, this is not the case. When a process is forked, any trapped
|
33
|
+
# signals will no longer get processed. This is because the thread that
|
34
|
+
# processes those incoming signals gets killed.
|
35
|
+
#
|
36
|
+
# In order to process these signals, `Chore::Signal.reset` must be called,
|
37
|
+
# followed by additional calls to re-register those signal handlers.
|
38
|
+
#
|
39
|
+
# == Signal ordering
|
40
|
+
#
|
41
|
+
# It is important to note that in Ruby, signals are essentially processed
|
42
|
+
# as LIFO (Last-In, First-Out) since they are interrupt driven. Similar
|
43
|
+
# behaviors is present in Chore's implementation.
|
44
|
+
#
|
45
|
+
# Having LIFO behavior is the reason why this class uses a queue for
|
46
|
+
# tracking the list of incoming signals, instead of writing them out to a
|
47
|
+
# pipe.
|
48
|
+
class Signal
|
49
|
+
# The handlers registered for trapping certain signals. Maps signal => handler.
|
50
|
+
@handlers = {}
|
51
|
+
|
52
|
+
# The set of incoming, unprocessed high-priority signals (such as QUIT / INT)
|
53
|
+
@primary_signals = []
|
54
|
+
|
55
|
+
# The set of incoming, unprocessed low-priority signals (such as CHLD)
|
56
|
+
@secondary_signals = []
|
57
|
+
|
58
|
+
# The priorities of signals to handle. If not defined, the signal is
|
59
|
+
# considered high-priority.
|
60
|
+
PRIORITIES = {
|
61
|
+
'CHLD' => :secondary
|
62
|
+
}
|
63
|
+
|
64
|
+
# Stream used to track when signals are ready to be processed
|
65
|
+
@wake_in, @wake_out = IO.pipe
|
66
|
+
|
67
|
+
class << self
|
68
|
+
# Traps the given signal and runs the block when the signal is sent to
|
69
|
+
# this process. This will run the block outside of the trap thread.
|
70
|
+
#
|
71
|
+
# Only a single handler can be registered for a signal at any point. If
|
72
|
+
# a signal has already been trapped, a warning will be generated and the
|
73
|
+
# previous handler for the signal will be returned.
|
74
|
+
#
|
75
|
+
# See ::Signal#trap @ http://ruby-doc.org/core-1.9.3/Signal.html#method-c-trap
|
76
|
+
# for more information.
|
77
|
+
def trap(signal, command = nil, &block)
|
78
|
+
if command
|
79
|
+
# Command given for Ruby to interpret -- pass it directly onto Signal
|
80
|
+
@handlers.delete(signal)
|
81
|
+
::Signal.trap(signal, command)
|
82
|
+
else
|
83
|
+
# Ensure we're listening for signals
|
84
|
+
listen
|
85
|
+
|
86
|
+
if @handlers[signal]
|
87
|
+
Chore.logger.debug "#{signal} signal has been overwritten:\n#{caller * "\n"}"
|
88
|
+
end
|
89
|
+
|
90
|
+
# Wrap handlers so they run in the listener thread
|
91
|
+
signals = PRIORITIES[signal] == :secondary ? @secondary_signals : @primary_signals
|
92
|
+
@handlers[signal] = block
|
93
|
+
::Signal.trap(signal) do
|
94
|
+
signals << signal
|
95
|
+
wakeup
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Resets signals and handlers back to their defaults. Any unprocessed
|
101
|
+
# signals will be discarded.
|
102
|
+
#
|
103
|
+
# This should be called after forking a processing in order to ensure
|
104
|
+
# that signals continue to get processed. *Note*, however, that new
|
105
|
+
# handlers must get registered after forking.
|
106
|
+
def reset
|
107
|
+
# Reset traps back to their default behavior. Note that this *must*
|
108
|
+
# be done first in order to prevent trap handlers from being called
|
109
|
+
# while the wake pipe / listener are being reset. If this is run
|
110
|
+
# out of order, then it's possible for those callbacks to hit errors.
|
111
|
+
@handlers.keys.each {|signal| trap(signal, 'DEFAULT')}
|
112
|
+
|
113
|
+
# Reset signals back to their empty state
|
114
|
+
@listener = nil
|
115
|
+
@primary_signals.clear
|
116
|
+
@secondary_signals.clear
|
117
|
+
@wake_out.close
|
118
|
+
@wake_in.close
|
119
|
+
@wake_in, @wake_out = IO.pipe
|
120
|
+
end
|
121
|
+
|
122
|
+
private
|
123
|
+
# Starts the thread that processes incoming signals
|
124
|
+
def listen
|
125
|
+
@listener ||= Thread.new do
|
126
|
+
on_wakeup do
|
127
|
+
while signal = next_signal
|
128
|
+
process(signal)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Looks up what the next signal is to process. Signals are typically
|
135
|
+
# processed LIFO (Last In, First Out), though primary signals are
|
136
|
+
# prioritized over secondary signals.
|
137
|
+
def next_signal
|
138
|
+
@primary_signals.pop || @secondary_signals.pop
|
139
|
+
end
|
140
|
+
|
141
|
+
# Waits until a wakeup signal is received. When it is received, the
|
142
|
+
# provided block will be yielded to.
|
143
|
+
def on_wakeup
|
144
|
+
begin
|
145
|
+
while @wake_in.getc
|
146
|
+
yield
|
147
|
+
end
|
148
|
+
rescue IOError => e
|
149
|
+
# Ignore: listener has been stopped
|
150
|
+
Chore.logger.debug "Signal stream closed: #{e}\n#{e.backtrace * "\n"}"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Wakes up the listener thread to indicate that signals are ready to be
|
155
|
+
# processed
|
156
|
+
def wakeup
|
157
|
+
@wake_out.write('.')
|
158
|
+
end
|
159
|
+
|
160
|
+
# Processes the given signal by running the handler in a separate
|
161
|
+
# thread.
|
162
|
+
def process(signal)
|
163
|
+
handler = @handlers[signal]
|
164
|
+
if handler
|
165
|
+
begin
|
166
|
+
handler.call
|
167
|
+
rescue => e
|
168
|
+
# Prevent signal handlers from killing the listener thread
|
169
|
+
Chore.logger.error "Failed to run #{signal} signal handler: #{e}\n#{e.backtrace * "\n"}"
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Chore
|
2
|
+
module Strategy
|
3
|
+
|
4
|
+
# Handles holding jobs in memory until such time as the batch has become full, per the developers configured threshold,
|
5
|
+
# or enough time elapses that Chore determines to not wait any longer (20 seconds by default)
|
6
|
+
class Batcher
|
7
|
+
attr_accessor :callback
|
8
|
+
attr_accessor :batch
|
9
|
+
|
10
|
+
def initialize(size)
|
11
|
+
@size = size
|
12
|
+
@batch = []
|
13
|
+
@mutex = Mutex.new
|
14
|
+
@last_message = nil
|
15
|
+
@callback = nil
|
16
|
+
@running = true
|
17
|
+
end
|
18
|
+
|
19
|
+
# The main entry point of the Batcher, <tt>schedule</tt> begins a thread with the provided +batch_timeout+
|
20
|
+
# as the only argument. While the Batcher is running, it will attempt to check if either the batch is full,
|
21
|
+
# or if the +batch_timeout+ has elapsed since the last batch was executed. If the batch is full, it will be executed.
|
22
|
+
# If the +batch_timeout+ has elapsed, as soon as the next message enters the batch, it will be executed.
|
23
|
+
#
|
24
|
+
# Calling <tt>stop</tt> will cause the thread to finish it's current check, and exit
|
25
|
+
def schedule(batch_timeout=20)
|
26
|
+
@thread = Thread.new(batch_timeout) do |timeout|
|
27
|
+
Chore.logger.info "Batching timeout thread starting"
|
28
|
+
while @running do
|
29
|
+
begin
|
30
|
+
Chore.logger.debug "Last message added to batch: #{@last_message}: #{@batch.size}"
|
31
|
+
if @last_message && Time.now > (@last_message + timeout)
|
32
|
+
Chore.logger.debug "Batching timeout reached (#{@last_message + timeout}), current size: #{@batch.size}"
|
33
|
+
self.execute(true)
|
34
|
+
@last_message = nil
|
35
|
+
end
|
36
|
+
sleep(1)
|
37
|
+
rescue => e
|
38
|
+
Chore.logger.error "Batcher#schedule raised an exception: #{e.inspect}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Adds the +item+ to the current batch
|
45
|
+
def add(item)
|
46
|
+
@batch << item
|
47
|
+
@last_message = Time.now
|
48
|
+
execute if ready?
|
49
|
+
end
|
50
|
+
|
51
|
+
# Calls for the batch to be executed. If +force+ is set to true, the batch will execute even if it is not full yet
|
52
|
+
def execute(force = false)
|
53
|
+
batch = nil
|
54
|
+
@mutex.synchronize do
|
55
|
+
if force || ready?
|
56
|
+
batch = @batch.slice!(0...@size)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
if batch && !batch.empty?
|
61
|
+
@callback.call(batch)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Determines if the batch is ready to fire, by comparing it's size to the configured batch_size
|
66
|
+
def ready?
|
67
|
+
@batch.size >= @size
|
68
|
+
end
|
69
|
+
|
70
|
+
# Sets a flag which will begin shutting down the Batcher
|
71
|
+
def stop
|
72
|
+
@running = false
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Chore
|
2
|
+
module Strategy
|
3
|
+
|
4
|
+
# Consumer strategy for requesting batches of work in a linear fashion. Ideally used for running
|
5
|
+
# a single Chore job locally in a development environment where performance or throughput may not matter.
|
6
|
+
# <tt>SingleConsumerStrategy</tt> will raise an exception if you're configured to listen for more than 1 queue
|
7
|
+
class SingleConsumerStrategy
|
8
|
+
def initialize(fetcher, opts={})
|
9
|
+
@fetcher = fetcher
|
10
|
+
end
|
11
|
+
|
12
|
+
# Begins fetching from the configured queue by way of the configured Consumer. This can only be used if you have a
|
13
|
+
# single queue which can be kept up with at a relatively low volume. If you have more than a single queue configured,
|
14
|
+
# it will raise an exception.
|
15
|
+
def fetch
|
16
|
+
Chore.logger.debug "Starting up consumer strategy: #{self.class.name}"
|
17
|
+
queues = Chore.config.queues
|
18
|
+
raise "When using SingleConsumerStrategy only one queue can be defined. Queues: #{queues}" unless queues.size == 1
|
19
|
+
|
20
|
+
@consumer = Chore.config.consumer.new(queues.first)
|
21
|
+
@consumer.consume do |id,queue_name,queue_timeout,body,previous_attempts|
|
22
|
+
work = UnitOfWork.new(id, queue_name, queue_timeout, body, previous_attempts, @consumer)
|
23
|
+
@fetcher.manager.assign(work)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Stops consuming messages from the queue
|
28
|
+
def stop!
|
29
|
+
Chore.logger.info "Shutting down fetcher: #{self.class.name.to_s}"
|
30
|
+
@consumer.stop
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'chore/strategies/consumer/batcher'
|
2
|
+
module Chore
|
3
|
+
module Strategy
|
4
|
+
class ThreadedConsumerStrategy #:nodoc
|
5
|
+
attr_accessor :batcher
|
6
|
+
|
7
|
+
Chore::CLI.register_option 'batch_size', '--batch-size SIZE', Integer, 'Number of items to collect for a single worker to process'
|
8
|
+
Chore::CLI.register_option 'threads_per_queue', '--threads-per-queue NUM_THREADS', Integer, 'Number of threads to create for each named queue'
|
9
|
+
|
10
|
+
def initialize(fetcher)
|
11
|
+
@fetcher = fetcher
|
12
|
+
@batcher = Batcher.new(Chore.config.batch_size)
|
13
|
+
@batcher.callback = lambda { |batch| @fetcher.manager.assign(batch) }
|
14
|
+
@batcher.schedule
|
15
|
+
@running = true
|
16
|
+
end
|
17
|
+
|
18
|
+
# Begins fetching from queues by spinning up the configured +:threads_per_queue:+ count of threads
|
19
|
+
# for each queue you're consuming from.
|
20
|
+
# Once all threads are spun up and running, the threads are then joined.
|
21
|
+
def fetch
|
22
|
+
Chore.logger.debug "Starting up consumer strategy: #{self.class.name}"
|
23
|
+
threads = []
|
24
|
+
Chore.config.queues.each do |queue|
|
25
|
+
Chore.config.threads_per_queue.times do
|
26
|
+
if running?
|
27
|
+
threads << start_consumer_thread(queue)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
threads.each(&:join)
|
33
|
+
end
|
34
|
+
|
35
|
+
# If the ThreadedConsumerStrategy is currently running <tt>stop!</tt> will begin signalling it to stop
|
36
|
+
# It will stop the batcher from forking more work, as well as set a flag which will disable it's own consuming
|
37
|
+
# threads once they finish with their current work.
|
38
|
+
def stop!
|
39
|
+
if running?
|
40
|
+
Chore.logger.info "Shutting down fetcher: #{self.class.name.to_s}"
|
41
|
+
@batcher.stop
|
42
|
+
@running = false
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns whether or not the ThreadedConsumerStrategy is running or not
|
47
|
+
def running?
|
48
|
+
@running
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
# Starts a consumer thread for polling the given +queue+.
|
53
|
+
# If <tt>stop!<tt> is called, the threads will shut themsevles down.
|
54
|
+
def start_consumer_thread(queue)
|
55
|
+
t = Thread.new(queue) do |tQueue|
|
56
|
+
begin
|
57
|
+
consumer = Chore.config.consumer.new(tQueue)
|
58
|
+
consumer.consume do |id, queue_name, queue_timeout, body, previous_attempts|
|
59
|
+
# Quick hack to force this thread to end it's work
|
60
|
+
# if we're shutting down. Could be delayed due to the
|
61
|
+
# weird sometimes-blocking nature of SQS.
|
62
|
+
consumer.stop if !running?
|
63
|
+
Chore.logger.debug { "Got message: #{id}"}
|
64
|
+
|
65
|
+
work = UnitOfWork.new(id, queue_name, queue_timeout, body, previous_attempts, consumer)
|
66
|
+
@batcher.add(work)
|
67
|
+
end
|
68
|
+
rescue Chore::TerribleMistake
|
69
|
+
Chore.logger.error "I've made a terrible mistake... shutting down Chore"
|
70
|
+
self.stop!
|
71
|
+
@fetcher.manager.shutdown!
|
72
|
+
rescue => e
|
73
|
+
Chore.logger.error "ThreadedConsumerStrategy#consumer thread raised an exception: #{e.inspect} at #{e.backtrace}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
t
|
77
|
+
end
|
78
|
+
|
79
|
+
end #ThreadedConsumerStrategy
|
80
|
+
end
|
81
|
+
end #Chore
|