chore-core 1.10.0 → 4.0.0
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 +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
|