chore-core 1.10.0 → 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -13
- data/LICENSE.txt +1 -1
- data/README.md +172 -153
- data/chore-core.gemspec +3 -3
- data/lib/chore.rb +29 -5
- data/lib/chore/cli.rb +22 -4
- data/lib/chore/configuration.rb +1 -1
- data/lib/chore/consumer.rb +54 -12
- data/lib/chore/fetcher.rb +12 -7
- data/lib/chore/hooks.rb +2 -1
- data/lib/chore/job.rb +19 -0
- data/lib/chore/manager.rb +17 -2
- data/lib/chore/publisher.rb +18 -2
- data/lib/chore/queues/filesystem/consumer.rb +126 -64
- data/lib/chore/queues/filesystem/filesystem_queue.rb +19 -0
- data/lib/chore/queues/filesystem/publisher.rb +10 -16
- data/lib/chore/queues/sqs.rb +22 -13
- data/lib/chore/queues/sqs/consumer.rb +64 -51
- data/lib/chore/queues/sqs/publisher.rb +26 -17
- data/lib/chore/strategies/consumer/batcher.rb +6 -6
- data/lib/chore/strategies/consumer/single_consumer_strategy.rb +5 -5
- data/lib/chore/strategies/consumer/threaded_consumer_strategy.rb +7 -6
- data/lib/chore/strategies/consumer/throttled_consumer_strategy.rb +120 -0
- data/lib/chore/strategies/worker/forked_worker_strategy.rb +5 -6
- data/lib/chore/strategies/worker/helpers/ipc.rb +87 -0
- data/lib/chore/strategies/worker/helpers/preforked_worker.rb +163 -0
- data/lib/chore/strategies/worker/helpers/work_distributor.rb +65 -0
- data/lib/chore/strategies/worker/helpers/worker_info.rb +13 -0
- data/lib/chore/strategies/worker/helpers/worker_killer.rb +40 -0
- data/lib/chore/strategies/worker/helpers/worker_manager.rb +183 -0
- data/lib/chore/strategies/worker/preforked_worker_strategy.rb +150 -0
- data/lib/chore/unit_of_work.rb +2 -1
- data/lib/chore/util.rb +5 -1
- data/lib/chore/version.rb +2 -2
- data/lib/chore/worker.rb +30 -3
- data/spec/chore/cli_spec.rb +2 -2
- data/spec/chore/consumer_spec.rb +1 -5
- data/spec/chore/duplicate_detector_spec.rb +17 -5
- data/spec/chore/fetcher_spec.rb +0 -11
- data/spec/chore/manager_spec.rb +7 -0
- data/spec/chore/queues/filesystem/filesystem_consumer_spec.rb +74 -16
- data/spec/chore/queues/sqs/consumer_spec.rb +117 -78
- data/spec/chore/queues/sqs/publisher_spec.rb +49 -60
- data/spec/chore/queues/sqs_spec.rb +32 -41
- data/spec/chore/strategies/consumer/single_consumer_strategy_spec.rb +3 -3
- data/spec/chore/strategies/consumer/threaded_consumer_strategy_spec.rb +6 -6
- data/spec/chore/strategies/consumer/throttled_consumer_strategy_spec.rb +165 -0
- data/spec/chore/strategies/worker/forked_worker_strategy_spec.rb +6 -1
- data/spec/chore/strategies/worker/helpers/ipc_spec.rb +127 -0
- data/spec/chore/strategies/worker/helpers/preforked_worker_spec.rb +236 -0
- data/spec/chore/strategies/worker/helpers/work_distributor_spec.rb +131 -0
- data/spec/chore/strategies/worker/helpers/worker_info_spec.rb +14 -0
- data/spec/chore/strategies/worker/helpers/worker_killer_spec.rb +97 -0
- data/spec/chore/strategies/worker/helpers/worker_manager_spec.rb +304 -0
- data/spec/chore/strategies/worker/preforked_worker_strategy_spec.rb +183 -0
- data/spec/chore/strategies/worker/single_worker_strategy_spec.rb +1 -1
- data/spec/chore/worker_spec.rb +70 -15
- data/spec/spec_helper.rb +1 -1
- data/spec/support/queues/sqs/fake_objects.rb +18 -0
- metadata +53 -29
@@ -15,61 +15,111 @@ module Chore
|
|
15
15
|
# Once complete job files are deleted.
|
16
16
|
# If rejected they are moved back into new and will be processed again. This may not be the
|
17
17
|
# desired behavior long term and we may want to add configuration to this class to allow more
|
18
|
-
# creating failure handling and retrying.
|
18
|
+
# creating failure handling and retrying.
|
19
19
|
class Consumer < Chore::Consumer
|
20
20
|
extend FilesystemQueue
|
21
21
|
|
22
22
|
Chore::CLI.register_option 'fs_queue_root', '--fs-queue-root DIRECTORY', 'Root directory for fs based queue'
|
23
|
-
|
24
|
-
FILE_QUEUE_MUTEXES = {}
|
25
23
|
|
26
24
|
class << self
|
27
|
-
# Cleans up
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
25
|
+
# Cleans up expired in-progress files by making them new again.
|
26
|
+
def cleanup(expiration_time, new_dir, in_progress_dir)
|
27
|
+
each_file(in_progress_dir) do |job_file|
|
28
|
+
id, previous_attempts, timestamp = file_info(job_file)
|
29
|
+
next if timestamp > expiration_time
|
30
|
+
|
31
|
+
begin
|
32
|
+
make_new_again(job_file, new_dir, in_progress_dir)
|
33
|
+
rescue Errno::ENOENT
|
34
|
+
# File no longer exists; skip since it's been recovered by another
|
35
|
+
# consumer
|
36
|
+
rescue ArgumentError
|
37
|
+
# Move operation was attempted at same time as another consumer;
|
38
|
+
# skip since the other process succeeded where this one didn't
|
39
|
+
end
|
35
40
|
end
|
36
41
|
end
|
37
42
|
|
38
|
-
|
39
|
-
|
43
|
+
# Moves job file to inprogress directory and returns the full path
|
44
|
+
# if the job was successfully locked by this consumer
|
45
|
+
def make_in_progress(job, new_dir, in_progress_dir, queue_timeout)
|
46
|
+
basename, previous_attempts, * = file_info(job)
|
47
|
+
|
48
|
+
from = File.join(new_dir, job)
|
49
|
+
# Add a timestamp to mark when the job was started
|
50
|
+
to = File.join(in_progress_dir, "#{basename}.#{previous_attempts}.#{Time.now.to_i}.job")
|
51
|
+
|
52
|
+
# If the file is non-zero, this means it was successfully written to
|
53
|
+
# by a publisher and we can attempt to move it to "in progress".
|
54
|
+
#
|
55
|
+
# There is a small window of time where the file can be zero, but
|
56
|
+
# the publisher hasn't finished writing to the file yet.
|
57
|
+
if !File.zero?(from)
|
58
|
+
File.open(from, "r") do |f|
|
59
|
+
# If the lock can't be obtained, that means it's been locked
|
60
|
+
# by another consumer or the publisher of the file) -- don't
|
61
|
+
# block and skip it
|
62
|
+
if f.flock(File::LOCK_EX | File::LOCK_NB)
|
63
|
+
FileUtils.mv(from, to)
|
64
|
+
to
|
65
|
+
end
|
66
|
+
end
|
67
|
+
elsif (Time.now - File.ctime(from)) >= queue_timeout
|
68
|
+
# The file is empty (zero bytes) and enough time has passed since
|
69
|
+
# the file was written that we can safely assume it will never
|
70
|
+
# get written to be the publisher.
|
71
|
+
#
|
72
|
+
# The scenario where this happens is when the publisher created
|
73
|
+
# the file, but the process was killed before it had a chance to
|
74
|
+
# actually write the data.
|
75
|
+
File.delete(from)
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
rescue Errno::ENOENT
|
79
|
+
# File no longer exists; skip it since it's been picked up by
|
80
|
+
# another consumer
|
40
81
|
end
|
41
82
|
|
83
|
+
# Moves job file to new directory and returns the full path
|
42
84
|
def make_new_again(job, new_dir, in_progress_dir)
|
43
85
|
basename, previous_attempts = file_info(job)
|
44
|
-
move_job(File.join(in_progress_dir, job), File.join(new_dir, "#{basename}.#{previous_attempts + 1}.job"))
|
45
|
-
end
|
46
86
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
# Once we get the lock the file is ours to move to mark it in progress
|
52
|
-
f.flock(File::LOCK_EX)
|
53
|
-
begin
|
54
|
-
FileUtils.mv(f.path, to)
|
55
|
-
ensure
|
56
|
-
f.flock(File::LOCK_UN) # yes we can unlock it after its been moved, I checked
|
57
|
-
end
|
87
|
+
from = File.join(in_progress_dir, job)
|
88
|
+
to = File.join(new_dir, "#{basename}.#{previous_attempts + 1}.job")
|
89
|
+
FileUtils.mv(from, to)
|
90
|
+
|
58
91
|
to
|
59
92
|
end
|
60
93
|
|
61
|
-
def
|
62
|
-
|
94
|
+
def each_file(path, limit = nil)
|
95
|
+
count = 0
|
96
|
+
|
97
|
+
Dir.foreach(path) do |file|
|
98
|
+
next if file.start_with?('.')
|
99
|
+
|
100
|
+
yield file
|
101
|
+
|
102
|
+
count += 1
|
103
|
+
break if limit && count >= limit
|
104
|
+
end
|
63
105
|
end
|
64
106
|
|
65
107
|
# Grabs the unique identifier for the job filename and the number of times
|
66
108
|
# it's been attempted (also based on the filename)
|
67
109
|
def file_info(job_file)
|
68
|
-
id, previous_attempts =
|
69
|
-
[id, previous_attempts.to_i]
|
110
|
+
id, previous_attempts, timestamp, * = job_file.split('.')
|
111
|
+
[id, previous_attempts.to_i, timestamp.to_i]
|
70
112
|
end
|
71
113
|
end
|
72
114
|
|
115
|
+
# The minimum number of seconds to allow to pass between checks for expired
|
116
|
+
# jobs on the filesystem.
|
117
|
+
#
|
118
|
+
# Since queue times are measured on the order of seconds, 1 second is the
|
119
|
+
# smallest duration. It also prevents us from burning a lot of CPU looking
|
120
|
+
# at expired jobs when the consumer sleep interval is less than 1 second.
|
121
|
+
EXPIRATION_CHECK_INTERVAL = 1
|
122
|
+
|
73
123
|
# The amount of time units of work can run before the queue considers
|
74
124
|
# them timed out. For filesystem queues, this is the global default.
|
75
125
|
attr_reader :queue_timeout
|
@@ -77,66 +127,79 @@ module Chore
|
|
77
127
|
def initialize(queue_name, opts={})
|
78
128
|
super(queue_name, opts)
|
79
129
|
|
80
|
-
# Even though putting these Mutexes in this hash is, by itself, not particularly threadsafe
|
81
|
-
# as long as some Mutex ends up in the queue after all consumers are created we're good
|
82
|
-
# as they are pulled from the queue and synchronized for file operations below
|
83
|
-
FILE_QUEUE_MUTEXES[@queue_name] ||= Mutex.new
|
84
|
-
|
85
130
|
@in_progress_dir = self.class.in_progress_dir(queue_name)
|
86
131
|
@new_dir = self.class.new_dir(queue_name)
|
87
|
-
@queue_timeout =
|
132
|
+
@queue_timeout = self.class.queue_timeout(queue_name)
|
88
133
|
end
|
89
134
|
|
90
|
-
def consume
|
135
|
+
def consume
|
91
136
|
Chore.logger.info "Starting consuming file system queue #{@queue_name} in #{self.class.queue_dir(queue_name)}"
|
92
137
|
while running?
|
93
138
|
begin
|
94
|
-
#
|
95
|
-
|
139
|
+
# Move expired job files to new directory (so long as enough time has
|
140
|
+
# passed since we last did this check)
|
141
|
+
if !@last_cleaned_at || (Time.now - @last_cleaned_at).to_i >= EXPIRATION_CHECK_INTERVAL
|
142
|
+
self.class.cleanup(Time.now.to_i - @queue_timeout, @new_dir, @in_progress_dir)
|
143
|
+
@last_cleaned_at = Time.now
|
144
|
+
end
|
145
|
+
|
146
|
+
found_files = false
|
147
|
+
handle_messages do |*args|
|
148
|
+
found_files = true
|
149
|
+
yield(*args)
|
150
|
+
end
|
96
151
|
rescue => e
|
97
152
|
Chore.logger.error { "#{self.class}#consume: #{e} #{e.backtrace * "\n"}" }
|
98
153
|
ensure
|
99
|
-
sleep
|
154
|
+
sleep(Chore.config.consumer_sleep_interval) unless found_files
|
100
155
|
end
|
101
156
|
end
|
102
157
|
end
|
103
158
|
|
159
|
+
# Rejects the given message from the filesystem by +id+. Currently a noop
|
104
160
|
def reject(id)
|
105
|
-
|
106
|
-
make_new_again(id)
|
161
|
+
|
107
162
|
end
|
108
163
|
|
109
|
-
|
110
|
-
|
111
|
-
|
164
|
+
# Deletes the given message from filesystem queue. Since the filesystem is not a remote API, there is no
|
165
|
+
# notion of a "receipt handle".
|
166
|
+
#
|
167
|
+
# @param [String] message_id Unique ID of the message
|
168
|
+
# @param [Hash] receipt_handle Receipt handle of the message. Always nil for the filesystem consumer
|
169
|
+
def complete(message_id, receipt_handle = nil)
|
170
|
+
Chore.logger.debug "Completing (deleting): #{message_id}"
|
171
|
+
File.delete(File.join(@in_progress_dir, message_id))
|
172
|
+
rescue Errno::ENOENT
|
173
|
+
# The job took too long to complete, was deemed expired, and moved
|
174
|
+
# back into "new". Ignore.
|
112
175
|
end
|
113
176
|
|
114
177
|
private
|
115
178
|
|
116
179
|
# finds all new job files, moves them to in progress and starts the job
|
117
180
|
# Returns a list of the job files processed
|
118
|
-
def
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
181
|
+
def handle_messages(&block)
|
182
|
+
self.class.each_file(@new_dir, Chore.config.queue_polling_size) do |job_file|
|
183
|
+
Chore.logger.debug "Found a new job #{job_file}"
|
184
|
+
|
185
|
+
in_progress_path = make_in_progress(job_file)
|
186
|
+
next unless in_progress_path
|
187
|
+
|
188
|
+
# The job filename may have changed, so update it to reflect the in progress path
|
189
|
+
job_file = File.basename(in_progress_path)
|
190
|
+
|
191
|
+
job_json = File.read(in_progress_path)
|
192
|
+
basename, previous_attempts, * = self.class.file_info(job_file)
|
193
|
+
|
194
|
+
# job_file is just the name which is the job id. 2nd argument (:receipt_handle) is nil because the
|
195
|
+
# filesystem is dealt with directly, as opposed to being an external API
|
196
|
+
block.call(job_file, nil, queue_name, queue_timeout, job_json, previous_attempts)
|
197
|
+
Chore.run_hooks_for(:on_fetch, job_file, job_json)
|
135
198
|
end
|
136
199
|
end
|
137
200
|
|
138
201
|
def make_in_progress(job)
|
139
|
-
self.class.make_in_progress(job, @new_dir, @in_progress_dir)
|
202
|
+
self.class.make_in_progress(job, @new_dir, @in_progress_dir, @queue_timeout)
|
140
203
|
end
|
141
204
|
|
142
205
|
def make_new_again(job)
|
@@ -146,4 +209,3 @@ module Chore
|
|
146
209
|
end
|
147
210
|
end
|
148
211
|
end
|
149
|
-
|
@@ -6,6 +6,8 @@ module Chore::FilesystemQueue
|
|
6
6
|
NEW_JOB_DIR = "new"
|
7
7
|
# Local directory for jobs currently in-process to be moved
|
8
8
|
IN_PROGRESS_DIR = "inprogress"
|
9
|
+
# Local directory for configuration info
|
10
|
+
CONFIG_DIR = "config"
|
9
11
|
|
10
12
|
# Retrieves the directory for in-process messages to go. If the directory for the +queue_name+ doesn't exist,
|
11
13
|
# it will be created for you. If the directory cannot be created, an IOError will be raised
|
@@ -29,6 +31,23 @@ module Chore::FilesystemQueue
|
|
29
31
|
prepare_dir(File.join(root_dir, queue_name))
|
30
32
|
end
|
31
33
|
|
34
|
+
# The configuration for the given queue
|
35
|
+
def config_dir(queue_name)
|
36
|
+
validate_dir(queue_name, CONFIG_DIR)
|
37
|
+
end
|
38
|
+
|
39
|
+
def config_value(queue_name, config_name)
|
40
|
+
config_file = File.join(config_dir(queue_name), config_name)
|
41
|
+
if File.exists?(config_file)
|
42
|
+
File.read(config_file).strip
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns the timeout for +queue_name+
|
47
|
+
def queue_timeout(queue_name)
|
48
|
+
(config_value(queue_name, 'timeout') || Chore.config.default_queue_timeout).to_i
|
49
|
+
end
|
50
|
+
|
32
51
|
private
|
33
52
|
# Returns the directory for the given +queue_name+ and +task_state+. If the directory doesn't exist, it will be
|
34
53
|
# created for you. If the directory cannot be created, an IOError will be raised
|
@@ -10,27 +10,20 @@ module Chore
|
|
10
10
|
# See the top of FilesystemConsumer for comments on how this works
|
11
11
|
include FilesystemQueue
|
12
12
|
|
13
|
-
# Mutex for holding a lock over the files for this queue while they are in process
|
14
|
-
FILE_MUTEX = Mutex.new
|
15
|
-
|
16
13
|
# use of mutex and file locking should make this both threadsafe and safe for multiple
|
17
|
-
# processes to use the same queue directory simultaneously.
|
14
|
+
# processes to use the same queue directory simultaneously.
|
18
15
|
def publish(queue_name,job)
|
19
16
|
# First try encoding the job to avoid writing empty job files if this fails
|
20
17
|
encoded_job = encode_job(job)
|
21
18
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
19
|
+
published = false
|
20
|
+
while !published
|
21
|
+
# keep trying to get a file with nothing in it meaning we just created it
|
22
|
+
# as opposed to us getting someone else's file that hasn't been processed yet.
|
23
|
+
File.open(filename(queue_name, job[:class].to_s), "a") do |f|
|
27
24
|
if f.flock(File::LOCK_EX | File::LOCK_NB) && f.size == 0
|
28
|
-
|
29
|
-
|
30
|
-
ensure
|
31
|
-
f.flock(File::LOCK_UN)
|
32
|
-
end
|
33
|
-
break
|
25
|
+
f.write(encoded_job)
|
26
|
+
published = true
|
34
27
|
end
|
35
28
|
end
|
36
29
|
end
|
@@ -40,7 +33,8 @@ module Chore
|
|
40
33
|
def filename(queue_name, job_name)
|
41
34
|
now = Time.now.strftime "%Y%m%d-%H%M%S-%6N"
|
42
35
|
previous_attempts = 0
|
43
|
-
|
36
|
+
pid = Process.pid
|
37
|
+
File.join(new_dir(queue_name), "#{queue_name}-#{job_name}-#{pid}-#{now}.#{previous_attempts}.job")
|
44
38
|
end
|
45
39
|
end
|
46
40
|
end
|
data/lib/chore/queues/sqs.rb
CHANGED
@@ -1,10 +1,18 @@
|
|
1
1
|
module Chore
|
2
2
|
module Queues
|
3
3
|
module SQS
|
4
|
+
def self.sqs_client
|
5
|
+
Aws::SQS::Client.new(logger: Chore.logger, log_level: Chore.log_level_to_sym)
|
6
|
+
end
|
7
|
+
|
4
8
|
# Helper method to create queues based on the currently known list as provided by your configured Chore::Jobs
|
5
9
|
# This is meant to be invoked from a rake task, and not directly.
|
6
10
|
# These queues will be created with the default settings, which may not be ideal.
|
7
11
|
# This is meant only as a convenience helper for testing, and not as a way to create production quality queues in SQS
|
12
|
+
#
|
13
|
+
# @param [TrueClass, FalseClass] halt_on_existing Raise an exception if the queue already exists
|
14
|
+
#
|
15
|
+
# @return [Array<String>]
|
8
16
|
def self.create_queues!(halt_on_existing=false)
|
9
17
|
raise 'You must have atleast one Chore Job configured and loaded before attempting to create queues' unless Chore.prefixed_queue_names.length > 0
|
10
18
|
|
@@ -20,49 +28,50 @@ module Chore
|
|
20
28
|
end
|
21
29
|
end
|
22
30
|
|
23
|
-
#This will raise an exception if AWS has not been configured by the project making use of Chore
|
24
|
-
sqs_queues = AWS::SQS.new.queues
|
25
31
|
Chore.prefixed_queue_names.each do |queue_name|
|
26
32
|
Chore.logger.info "Chore Creating Queue: #{queue_name}"
|
27
33
|
begin
|
28
|
-
|
29
|
-
rescue
|
34
|
+
sqs_client.create_queue(queue_name: queue_name)
|
35
|
+
rescue Aws::SQS::Errors::QueueAlreadyExists
|
30
36
|
Chore.logger.info "exists with different config"
|
31
37
|
end
|
32
38
|
end
|
39
|
+
|
33
40
|
Chore.prefixed_queue_names
|
34
41
|
end
|
35
42
|
|
36
43
|
# Helper method to delete all known queues based on the list as provided by your configured Chore::Jobs
|
37
44
|
# This is meant to be invoked from a rake task, and not directly.
|
45
|
+
#
|
46
|
+
# @return [Array<String>]
|
47
|
+
|
38
48
|
def self.delete_queues!
|
39
49
|
raise 'You must have atleast one Chore Job configured and loaded before attempting to create queues' unless Chore.prefixed_queue_names.length > 0
|
40
|
-
|
41
|
-
sqs_queues = AWS::SQS.new.queues
|
50
|
+
|
42
51
|
Chore.prefixed_queue_names.each do |queue_name|
|
43
52
|
begin
|
44
53
|
Chore.logger.info "Chore Deleting Queue: #{queue_name}"
|
45
|
-
url =
|
46
|
-
|
54
|
+
url = sqs_client.get_queue_url(queue_name: queue_name).queue_url
|
55
|
+
sqs_client.delete_queue(queue_url: url)
|
47
56
|
rescue => e
|
48
57
|
# This could fail for a few reasons - log out why it failed, then continue on
|
49
58
|
Chore.logger.error "Deleting Queue: #{queue_name} failed because #{e}"
|
50
59
|
end
|
51
60
|
end
|
61
|
+
|
52
62
|
Chore.prefixed_queue_names
|
53
63
|
end
|
54
64
|
|
55
65
|
# Collect a list of queues that already exist
|
66
|
+
#
|
67
|
+
# @return [Array<String>]
|
56
68
|
def self.existing_queues
|
57
|
-
#This will raise an exception if AWS has not been configured by the project making use of Chore
|
58
|
-
sqs_queues = AWS::SQS.new.queues
|
59
|
-
|
60
69
|
Chore.prefixed_queue_names.select do |queue_name|
|
61
70
|
# If the NonExistentQueue exception is raised we do not care about that queue name.
|
62
71
|
begin
|
63
|
-
|
72
|
+
sqs_client.get_queue_url(queue_name: queue_name)
|
64
73
|
true
|
65
|
-
rescue
|
74
|
+
rescue Aws::SQS::Errors::NonExistentQueue
|
66
75
|
false
|
67
76
|
end
|
68
77
|
end
|
@@ -1,9 +1,6 @@
|
|
1
|
-
require 'aws
|
1
|
+
require 'aws-sdk-sqs'
|
2
2
|
require 'chore/duplicate_detector'
|
3
3
|
|
4
|
-
AWS.eager_autoload! AWS::Core
|
5
|
-
AWS.eager_autoload! AWS::SQS
|
6
|
-
|
7
4
|
module Chore
|
8
5
|
module Queues
|
9
6
|
module SQS
|
@@ -16,26 +13,30 @@ module Chore
|
|
16
13
|
Chore::CLI.register_option 'aws_access_key', '--aws-access-key KEY', 'Valid AWS Access Key'
|
17
14
|
Chore::CLI.register_option 'aws_secret_key', '--aws-secret-key KEY', 'Valid AWS Secret Key'
|
18
15
|
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
16
|
|
17
|
+
# @param [String] queue_name Name of SQS queue
|
18
|
+
# @param [Hash] opts Options
|
23
19
|
def initialize(queue_name, opts={})
|
24
20
|
super(queue_name, opts)
|
21
|
+
raise Chore::TerribleMistake, "Cannot specify a queue polling size greater than 10" if sqs_polling_amount > 10
|
25
22
|
end
|
26
23
|
|
27
|
-
#
|
24
|
+
# Resets the API client connection and provides @@reset_at so we know when the last time that was done
|
28
25
|
def self.reset_connection!
|
29
26
|
@@reset_at = Time.now
|
30
27
|
end
|
31
28
|
|
32
29
|
# Begins requesting messages from SQS, which will invoke the +&handler+ over each message
|
30
|
+
#
|
31
|
+
# @param [Block] &handler Message handler, used by the calling context (worker) to create & assigns a UnitOfWork
|
32
|
+
#
|
33
|
+
# @return [Array<Aws::SQS::Message>]
|
33
34
|
def consume(&handler)
|
34
35
|
while running?
|
35
36
|
begin
|
36
37
|
messages = handle_messages(&handler)
|
37
|
-
sleep (Chore.config.consumer_sleep_interval
|
38
|
-
rescue
|
38
|
+
sleep (Chore.config.consumer_sleep_interval) if messages.empty?
|
39
|
+
rescue Aws::SQS::Errors::NonExistentQueue => e
|
39
40
|
Chore.logger.error "You specified a queue '#{queue_name}' that does not exist. You must create the queue before starting Chore. Shutting down..."
|
40
41
|
raise Chore::TerribleMistake
|
41
42
|
rescue => e
|
@@ -44,21 +45,34 @@ module Chore
|
|
44
45
|
end
|
45
46
|
end
|
46
47
|
|
47
|
-
# Rejects the given message from SQS
|
48
|
-
|
49
|
-
|
48
|
+
# Unimplemented. Rejects the given message from SQS.
|
49
|
+
#
|
50
|
+
# @param [String] message_id Unique ID of the SQS message
|
51
|
+
#
|
52
|
+
# @return nil
|
53
|
+
def reject(message_id)
|
50
54
|
end
|
51
55
|
|
52
|
-
# Deletes the given message from SQS
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
+
# Deletes the given message from the SQS queue
|
57
|
+
#
|
58
|
+
# @param [String] message_id Unique ID of the SQS message
|
59
|
+
# @param [Hash] receipt_handle Receipt handle (unique per consume request) of the SQS message
|
60
|
+
def complete(message_id, receipt_handle)
|
61
|
+
Chore.logger.debug "Completing (deleting): #{message_id}"
|
62
|
+
queue.delete_messages(entries: [{ id: message_id, receipt_handle: receipt_handle }])
|
56
63
|
end
|
57
64
|
|
65
|
+
# Delays retry of a job by +backoff_calc+ seconds.
|
66
|
+
#
|
67
|
+
# @param [UnitOfWork] item Item to be delayed
|
68
|
+
# @param [Block] backoff_calc Code that determines the backoff.
|
58
69
|
def delay(item, backoff_calc)
|
59
70
|
delay = backoff_calc.call(item)
|
60
71
|
Chore.logger.debug "Delaying #{item.id} by #{delay} seconds"
|
61
|
-
|
72
|
+
|
73
|
+
queue.change_message_visibility_batch(entries: [
|
74
|
+
{ id: item.id, receipt_handle: item.receipt_handle, visibility_timeout: delay },
|
75
|
+
])
|
62
76
|
|
63
77
|
return delay
|
64
78
|
end
|
@@ -67,62 +81,61 @@ module Chore
|
|
67
81
|
|
68
82
|
# Requests messages from SQS, and invokes the provided +&block+ over each one. Afterwards, the :on_fetch
|
69
83
|
# hook will be invoked, per message
|
84
|
+
#
|
85
|
+
# @param [Block] &handler Message handler, passed along by #consume
|
86
|
+
#
|
87
|
+
# @return [Array<Aws::SQS::Message>]
|
70
88
|
def handle_messages(&block)
|
71
|
-
msg = queue.receive_messages(:
|
89
|
+
msg = queue.receive_messages(:max_number_of_messages => sqs_polling_amount, :attribute_names => ['ApproximateReceiveCount'])
|
72
90
|
messages = *msg
|
91
|
+
|
73
92
|
messages.each do |message|
|
74
|
-
unless duplicate_message?(message)
|
75
|
-
block.call(message.
|
93
|
+
unless duplicate_message?(message.message_id, message.queue_url, queue_timeout)
|
94
|
+
block.call(message.message_id, message.receipt_handle, queue_name, queue_timeout, message.body, message.attributes['ApproximateReceiveCount'].to_i - 1)
|
76
95
|
end
|
77
|
-
Chore.run_hooks_for(:on_fetch, message.
|
96
|
+
Chore.run_hooks_for(:on_fetch, message.receipt_handle, message.body)
|
78
97
|
end
|
79
|
-
messages
|
80
|
-
end
|
81
|
-
|
82
|
-
# Checks if the given message has already been received within the timeout window for this queue
|
83
|
-
def duplicate_message?(message)
|
84
|
-
dupe_detector.found_duplicate?(:id=>message.id, :queue=>message.queue.url, :visibility_timeout=>message.queue.visibility_timeout)
|
85
|
-
end
|
86
98
|
|
87
|
-
|
88
|
-
# Will create one if one doesn't already exist
|
89
|
-
def dupe_detector
|
90
|
-
@dupes ||= DuplicateDetector.new({:servers => Chore.config.dedupe_servers,
|
91
|
-
:dupe_on_cache_failure => Chore.config.dupe_on_cache_failure})
|
99
|
+
messages
|
92
100
|
end
|
93
101
|
|
94
|
-
# Retrieves the SQS queue
|
95
|
-
#
|
96
|
-
#
|
102
|
+
# Retrieves the SQS queue object. The method will cache the results to prevent round trips on subsequent calls
|
103
|
+
#
|
104
|
+
# If <tt>reset_connection!</tt> has been called, this will result in the connection being re-initialized,
|
105
|
+
# as well as clear any cached results from prior calls
|
106
|
+
#
|
107
|
+
# @return [Aws::SQS::Queue]
|
97
108
|
def queue
|
98
109
|
if !@sqs_last_connected || (@@reset_at && @@reset_at >= @sqs_last_connected)
|
99
|
-
|
100
|
-
p.empty!
|
101
|
-
end
|
110
|
+
Aws.empty_connection_pools!
|
102
111
|
@sqs = nil
|
103
112
|
@sqs_last_connected = Time.now
|
104
113
|
@queue = nil
|
105
114
|
end
|
106
|
-
|
107
|
-
@
|
115
|
+
|
116
|
+
@queue_url ||= sqs.get_queue_url(queue_name: @queue_name).queue_url
|
117
|
+
@queue ||= Aws::SQS::Queue.new(url: @queue_url, client: sqs)
|
108
118
|
end
|
109
119
|
|
110
|
-
# The visibility timeout of the queue
|
120
|
+
# The visibility timeout (in seconds) of the queue
|
121
|
+
#
|
122
|
+
# @return [Integer]
|
111
123
|
def queue_timeout
|
112
|
-
@queue_timeout ||= queue.
|
124
|
+
@queue_timeout ||= queue.attributes['VisibilityTimeout'].to_i
|
113
125
|
end
|
114
126
|
|
115
|
-
#
|
127
|
+
# SQS API client object
|
128
|
+
#
|
129
|
+
# @return [Aws::SQS::Client]
|
116
130
|
def sqs
|
117
|
-
@sqs ||=
|
118
|
-
:access_key_id => Chore.config.aws_access_key,
|
119
|
-
:secret_access_key => Chore.config.aws_secret_key,
|
120
|
-
:logger => Chore.logger,
|
121
|
-
:log_level => :debug)
|
131
|
+
@sqs ||= Chore::Queues::SQS.sqs_client
|
122
132
|
end
|
123
133
|
|
134
|
+
# Maximum number of messages to retrieve on each request
|
135
|
+
#
|
136
|
+
# @return [Integer]
|
124
137
|
def sqs_polling_amount
|
125
|
-
Chore.config.queue_polling_size
|
138
|
+
Chore.config.queue_polling_size
|
126
139
|
end
|
127
140
|
end
|
128
141
|
end
|