parallel_minion 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +234 -0
- data/Rakefile +23 -0
- data/lib/parallel_minion.rb +8 -0
- data/lib/parallel_minion/horde.rb +41 -0
- data/lib/parallel_minion/minion.rb +237 -0
- data/lib/parallel_minion/railtie.rb +20 -0
- data/lib/parallel_minion/version.rb +3 -0
- data/test/config/database.yml +5 -0
- data/test/minion_scope_test.rb +74 -0
- data/test/minion_test.rb +109 -0
- data/test/test_db.sqlite3 +0 -0
- metadata +75 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: a490fae075880a809d3d3542381454e2737ee560
|
4
|
+
data.tar.gz: bec1bb591e8d19b490821f5153cb195083e9e58a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 49c1e58bcb4011633cd0f80d666a6872f8fea1719cb5e2085dc463e6afd0ed71672ee1e49c5846fca668a0704beebdf22f0b816d804a7590f88e02571bf7ecd6
|
7
|
+
data.tar.gz: 383505b36f1990379463117143231f2c069dc3451658a05fcc8d5c303a1394cdf76a232a4439f0d90660e8af3305ddc6445f5c74171f75f70940df7f0f43f534
|
data/README.md
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
parallel_minion
|
2
|
+
===============
|
3
|
+
|
4
|
+
Parallel Minion supports easily handing work off to Minions (Threads) so that tasks
|
5
|
+
that would normally be performed sequentially can easily be executed in parallel.
|
6
|
+
This allows Ruby and Rails applications to very easily do many tasks at the same
|
7
|
+
time so that results are returned more quickly.
|
8
|
+
|
9
|
+
Our use-case for Minions is where an application grew to a point where it would
|
10
|
+
be useful to run some of the steps in fulfilling a single request in parallel.
|
11
|
+
|
12
|
+
## Features:
|
13
|
+
|
14
|
+
Exceptions
|
15
|
+
|
16
|
+
- Any exceptions raised in Minions are captured and propagated back to the
|
17
|
+
calling thread when #result is called
|
18
|
+
- Makes exception handling simple with a drop-in replacement in existing code
|
19
|
+
|
20
|
+
Timeouts
|
21
|
+
|
22
|
+
- Timeout when a Minion does not return within a specified time
|
23
|
+
- Timeouts are a useful feature when one of the Minions fails to respond in a
|
24
|
+
reasonable amount of time. For example when a call to a remote service hangs
|
25
|
+
we can send back a partial response of other work that was completed rather
|
26
|
+
than just "hanging" or failing completely.
|
27
|
+
|
28
|
+
Logging
|
29
|
+
|
30
|
+
- Built-in support to log the duration of all Minion tasks to make future analysis
|
31
|
+
of performance issues much easier
|
32
|
+
- Logs any exceptions thrown to assist fwith problem diagnosis
|
33
|
+
|
34
|
+
## Example
|
35
|
+
|
36
|
+
Simple example
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
ParallelMinion::Minion.new(10.days.ago, description: 'Doing something else in parallel', timeout: 1000) do |date|
|
40
|
+
MyTable.where('created_at <= ?', date).count
|
41
|
+
end
|
42
|
+
```
|
43
|
+
|
44
|
+
## Example
|
45
|
+
|
46
|
+
For example, in the code below there are several steps that are performed sequentially:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
# Contrived example to show how to do parallel code execution
|
50
|
+
# with (unreal) sample durations in the comments
|
51
|
+
|
52
|
+
def process_request(request)
|
53
|
+
# Count number of entries in a table.
|
54
|
+
# Average response time 150ms
|
55
|
+
person_count = Person.where(state: 'FL').count
|
56
|
+
|
57
|
+
# Count the number of requests for this user (usually more complex with were clauses etc.)
|
58
|
+
# Average response time 320ms
|
59
|
+
request_count = Requests.where(user_id: request.user.id).count
|
60
|
+
|
61
|
+
# Call an external provider
|
62
|
+
# Average response time 1800ms ( Sometimes "hangs" when supplier does not respond )
|
63
|
+
inventory = inventory_supplier.check_inventory(request.product.id)
|
64
|
+
|
65
|
+
# Call another provider for more info on the user
|
66
|
+
# Average response time 1500ms
|
67
|
+
user_info = user_supplier.more_info(request.user.name)
|
68
|
+
|
69
|
+
# Build up the reply
|
70
|
+
reply = MyReply.new(user_id: request.user.id)
|
71
|
+
|
72
|
+
reply.number_of_people = person_count
|
73
|
+
reply.number_of_requests = request_count
|
74
|
+
reply.user_details = user_info.details
|
75
|
+
if inventory.product_available?
|
76
|
+
reply.available = true
|
77
|
+
reply.quantity = 100
|
78
|
+
else
|
79
|
+
reply.available = false
|
80
|
+
end
|
81
|
+
|
82
|
+
reply
|
83
|
+
end
|
84
|
+
```
|
85
|
+
The average response time when calling #process_request is around 3,780 milli-seconds.
|
86
|
+
|
87
|
+
The first step could be to run the supplier calls in parallel.
|
88
|
+
Through log analysis we have determined that the first supplier call takes on average
|
89
|
+
1,800 ms and we have decided that it should not wait longer than 2,200 ms for a response.
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
# Now with a single parallel call
|
93
|
+
|
94
|
+
def process_request(request)
|
95
|
+
# Count number of entries in a table.
|
96
|
+
# Average response time 150ms
|
97
|
+
person_count = Person.where(state: 'FL').count
|
98
|
+
|
99
|
+
# Count the number of requests for this user (usually more complex with were clauses etc.)
|
100
|
+
# Average response time 320ms
|
101
|
+
request_count = Requests.where(user_id: request.user.id).count
|
102
|
+
|
103
|
+
# Call an external provider
|
104
|
+
# Average response time 1800ms ( Sometimes "hangs" when supplier does not respond )
|
105
|
+
inventory_minion = ParallelMinion::Minion.new(request.product.id, description: 'Inventory Lookup', timeout: 2200) do |product_id|
|
106
|
+
inventory_supplier.check_inventory(product_id)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Call another provider for more info on the user
|
110
|
+
# Average response time 1500ms
|
111
|
+
user_info = user_supplier.more_info(request.user.name)
|
112
|
+
|
113
|
+
# Build up the reply
|
114
|
+
reply = MyReply.new(user_id: request.user.id)
|
115
|
+
|
116
|
+
reply.number_of_people = person_count
|
117
|
+
reply.number_of_requests = request_count
|
118
|
+
reply.user_details = user_info.details
|
119
|
+
|
120
|
+
# Get inventory result from Inventory Lookup minion
|
121
|
+
inventory = inventory_minion.result
|
122
|
+
|
123
|
+
if inventory.product_available?
|
124
|
+
reply.available = true
|
125
|
+
reply.quantity = 100
|
126
|
+
else
|
127
|
+
reply.available = false
|
128
|
+
end
|
129
|
+
|
130
|
+
reply
|
131
|
+
end
|
132
|
+
```
|
133
|
+
|
134
|
+
The above changes drop the average processing time from 3,780 milli-seconds to
|
135
|
+
2,280 milli-seconds.
|
136
|
+
|
137
|
+
By moving the supplier call to the top of the function call it can be optimized
|
138
|
+
to about 1,970 milli-seconds.
|
139
|
+
|
140
|
+
We can further parallelize the processing to gain even greater performance.
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
# Now with two parallel calls
|
144
|
+
|
145
|
+
def process_request(request)
|
146
|
+
# Call an external provider
|
147
|
+
# Average response time 1800ms ( Sometimes "hangs" when supplier does not respond )
|
148
|
+
inventory_minion = ParallelMinion::Minion.new(request.product.id, description: 'Inventory Lookup', timeout: 2200) do |product_id|
|
149
|
+
inventory_supplier.check_inventory(product_id)
|
150
|
+
end
|
151
|
+
|
152
|
+
# Count the number of requests for this user (usually more complex with were clauses etc.)
|
153
|
+
# Average response time 320ms
|
154
|
+
request_count_minion = ParallelMinion::Minion.new(request.user.id, description: 'Request Count', timeout: 500) do |user_id|
|
155
|
+
Requests.where(user_id: user_id).count
|
156
|
+
end
|
157
|
+
|
158
|
+
# Leave the current thread some work to do too
|
159
|
+
|
160
|
+
# Count number of entries in a table.
|
161
|
+
# Average response time 150ms
|
162
|
+
person_count = Person.where(state: 'FL').count
|
163
|
+
|
164
|
+
# Call another provider for more info on the user
|
165
|
+
# Average response time 1500ms
|
166
|
+
user_info = user_supplier.more_info(request.user.name)
|
167
|
+
|
168
|
+
# Build up the reply
|
169
|
+
reply = MyReply.new(user_id: request.user.id)
|
170
|
+
|
171
|
+
reply.number_of_people = person_count
|
172
|
+
# The request_count is retrieved from the request_count_minion first since it
|
173
|
+
# should complete before the inventory_minion
|
174
|
+
reply.number_of_requests = request_count_minion.result
|
175
|
+
reply.user_details = user_info.details
|
176
|
+
|
177
|
+
# Get inventory result from Inventory Lookup minion
|
178
|
+
inventory = inventory_minion.result
|
179
|
+
|
180
|
+
if inventory.product_available?
|
181
|
+
reply.available = true
|
182
|
+
reply.quantity = 100
|
183
|
+
else
|
184
|
+
reply.available = false
|
185
|
+
end
|
186
|
+
|
187
|
+
reply
|
188
|
+
end
|
189
|
+
```
|
190
|
+
|
191
|
+
The above #process_request method should now take on average 1,810 milli-seconds
|
192
|
+
which is significantly faster than the 3,780 milli-seconds it took to perform
|
193
|
+
the exact same request, but using only a single thread
|
194
|
+
|
195
|
+
The exact breakdown of which calls to do in the main thread versus a Minion is determined
|
196
|
+
through experience and trial and error over time. The key is logging the duration
|
197
|
+
of each call which Minion does by default so that the exact processing breakdown
|
198
|
+
can be fine-tuned over time.
|
199
|
+
|
200
|
+
Meta
|
201
|
+
----
|
202
|
+
|
203
|
+
* Code: `git clone git://github.com/reidmorrison/parallel_minion.git`
|
204
|
+
* Home: <https://github.com/reidmorrison/parallel_minion>
|
205
|
+
* Bugs: <http://github.com/reidmorrison/parallel_minion/issues>
|
206
|
+
* Gems: <http://rubygems.org/gems/parallel_minion>
|
207
|
+
|
208
|
+
This project uses [Semantic Versioning](http://semver.org/).
|
209
|
+
|
210
|
+
Author
|
211
|
+
-------
|
212
|
+
|
213
|
+
Reid Morrison :: reidmo@gmail.com :: @reidmorrison
|
214
|
+
|
215
|
+
Contributors
|
216
|
+
------------
|
217
|
+
|
218
|
+
|
219
|
+
License
|
220
|
+
-------
|
221
|
+
|
222
|
+
Copyright 2013 Reid Morrison
|
223
|
+
|
224
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
225
|
+
you may not use this file except in compliance with the License.
|
226
|
+
You may obtain a copy of the License at
|
227
|
+
|
228
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
229
|
+
|
230
|
+
Unless required by applicable law or agreed to in writing, software
|
231
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
232
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
233
|
+
See the License for the specific language governing permissions and
|
234
|
+
limitations under the License.
|
data/Rakefile
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
lib = File.expand_path('../lib/', __FILE__)
|
2
|
+
$:.unshift lib unless $:.include?(lib)
|
3
|
+
|
4
|
+
require 'rubygems'
|
5
|
+
require 'rubygems/package'
|
6
|
+
require 'rake/clean'
|
7
|
+
require 'rake/testtask'
|
8
|
+
require 'parallel_minion/version'
|
9
|
+
|
10
|
+
desc "Build gem"
|
11
|
+
task :gem do |t|
|
12
|
+
Gem::Package.build(Gem::Specification.load('parallel_minion.gemspec'))
|
13
|
+
end
|
14
|
+
|
15
|
+
desc "Run Test Suite"
|
16
|
+
task :test do
|
17
|
+
Rake::TestTask.new(:functional) do |t|
|
18
|
+
t.test_files = FileList['test/*_test.rb']
|
19
|
+
t.verbose = true
|
20
|
+
end
|
21
|
+
|
22
|
+
Rake::Task['functional'].invoke
|
23
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'minion'
|
2
|
+
|
3
|
+
# A Horde is way to manage a group of Minions
|
4
|
+
#
|
5
|
+
# Horde supports the following features for minions
|
6
|
+
# - Limit the number of Minions in the Horde to prevent overloading the system
|
7
|
+
# - Queue up requests for Minions when the lmit is reached
|
8
|
+
# - Optionally block submitting work for Minions when the queued up requests
|
9
|
+
# reach a specified number
|
10
|
+
class Horde
|
11
|
+
include SemanticLogger::Loggable
|
12
|
+
|
13
|
+
# Returns the description for this Horde
|
14
|
+
attr_reader :description
|
15
|
+
|
16
|
+
# Returns the maximum number of Minions active in this Horde at any time
|
17
|
+
attr_reader :capacity
|
18
|
+
|
19
|
+
# Returns the maximum number of queued up requests before blocking
|
20
|
+
# new requests for work
|
21
|
+
attr_reader :max_queue_size
|
22
|
+
|
23
|
+
# Create a new Horde of Minions
|
24
|
+
#
|
25
|
+
# Parameters
|
26
|
+
# :description
|
27
|
+
# Description for this task that the Minion is performing
|
28
|
+
# Put in the log file along with the time take to complete the task
|
29
|
+
#
|
30
|
+
# :capacity
|
31
|
+
# Maximum number of Minions active in this Horde at any time
|
32
|
+
# Default: 10
|
33
|
+
#
|
34
|
+
# :max_queue_size
|
35
|
+
# Maximum number of queued up requests before blocking
|
36
|
+
# new requests for work
|
37
|
+
# Default: -1 (unlimited)
|
38
|
+
#
|
39
|
+
def initialize(params={})
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,237 @@
|
|
1
|
+
# Instruct a Minion to perform a specific task in a separate thread
|
2
|
+
module ParallelMinion
|
3
|
+
class Minion
|
4
|
+
include SemanticLogger::Loggable
|
5
|
+
|
6
|
+
# Returns [String] the description supplied on the initializer
|
7
|
+
attr_reader :description
|
8
|
+
|
9
|
+
# Returns [Exception] the exception that was raised otherwise nil
|
10
|
+
attr_reader :exception
|
11
|
+
|
12
|
+
# Returns [Integer] the maximum duration in milli-seconds that the Minion may take to complete the task
|
13
|
+
attr_reader :timeout
|
14
|
+
|
15
|
+
# Give an infinite amount of time to wait for a Minion to complete a task
|
16
|
+
INFINITE = -1
|
17
|
+
|
18
|
+
# Sets whether to run in Synchronous mode
|
19
|
+
#
|
20
|
+
# By Setting synchronous to true all Minions that have not yet been started
|
21
|
+
# will run in the thread from which they are started and not in their own
|
22
|
+
# threads
|
23
|
+
#
|
24
|
+
# This is useful:
|
25
|
+
# - to run tests under the Capybara gem
|
26
|
+
# - when debugging code so that all code is run sequentially in the current thread
|
27
|
+
#
|
28
|
+
# Note: Do not set this setting to true in Production
|
29
|
+
def self.synchronous=(synchronous)
|
30
|
+
@@synchronous = synchronous
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns whether running in Synchronous mode
|
34
|
+
def self.synchronous?
|
35
|
+
@@synchronous
|
36
|
+
end
|
37
|
+
|
38
|
+
# The list of classes for which the current scope must be copied into the
|
39
|
+
# new Minion (Thread)
|
40
|
+
#
|
41
|
+
# Example:
|
42
|
+
# ...
|
43
|
+
def self.scoped_classes
|
44
|
+
@@scoped_classes
|
45
|
+
end
|
46
|
+
|
47
|
+
# Create a new thread and
|
48
|
+
# Log the time for the thread to complete processing
|
49
|
+
# The exception without stack trace is logged whenever an exception is
|
50
|
+
# thrown in the thread
|
51
|
+
# Re-raises any unhandled exception in the calling thread when it call #result
|
52
|
+
# Copy the logging tags and specified ActiveRecord scopes to the new thread
|
53
|
+
#
|
54
|
+
# Parameters
|
55
|
+
# :description [String]
|
56
|
+
# Description for this task that the Minion is performing
|
57
|
+
# Put in the log file along with the time take to complete the task
|
58
|
+
#
|
59
|
+
# :timeout [Integer]
|
60
|
+
# Maximum amount of time in milli-seconds that the task may take to complete
|
61
|
+
# before #result times out
|
62
|
+
# Set to Minion::INFINITE to give the thread an infinite amount of time to complete
|
63
|
+
# Default: Minion::INFINITE
|
64
|
+
#
|
65
|
+
# Notes:
|
66
|
+
# - :timeout does not affect what happens to the Minion running the
|
67
|
+
# the task, it only affects how long #result will take to return.
|
68
|
+
# - The Minion will continue to run even after the timeout has been exceeded
|
69
|
+
# - If :synchronous is true, or ParallelMinion::Minion.synchronous is
|
70
|
+
# set to true, then :timeout is ignored and assumed to be Minion::INFINITE
|
71
|
+
# since the code is run in the calling thread when the Minion is created
|
72
|
+
#
|
73
|
+
# :synchronous [Boolean]
|
74
|
+
# Whether the Minion should run in the current thread
|
75
|
+
# Not recommended in Production, but is useful for debugging purposes
|
76
|
+
# Default: false
|
77
|
+
#
|
78
|
+
# *args
|
79
|
+
# Any number of arguments can be supplied that are passed into the block
|
80
|
+
# in the order they are listed
|
81
|
+
# It is recommended to duplicate and/or freeze objects passed as arguments
|
82
|
+
# so that they are not modified at the same time by multiple threads
|
83
|
+
#
|
84
|
+
# Proc / lambda
|
85
|
+
# A block of code must be supplied that the Minion will execute
|
86
|
+
# NOTE: This block will be executed within the scope of the created minion
|
87
|
+
# instance and _not_ within the scope of where the Proc/lambda was
|
88
|
+
# originally created.
|
89
|
+
# This is done to force all parameters to be passed in explicitly
|
90
|
+
# and should be read-only or duplicates of the original data
|
91
|
+
#
|
92
|
+
# The overhead for moving the task to a Minion (separate thread) vs running it
|
93
|
+
# sequentially is about 0.3 ms if performing other tasks in-between starting
|
94
|
+
# the task and requesting its result.
|
95
|
+
#
|
96
|
+
# The following call adds 0.5 ms to total processing time vs running the
|
97
|
+
# code in-line:
|
98
|
+
# ParallelMinion::Minion.new(description: 'Count', timeout: 5) { 1 }.result
|
99
|
+
#
|
100
|
+
# NOTE:
|
101
|
+
# On JRuby it is very important to add the following setting to .jrubyrc
|
102
|
+
# thread.pool.enabled=true
|
103
|
+
#
|
104
|
+
# Example:
|
105
|
+
# ParallelMinion::Minion.new(10.days.ago, description: 'Doing something else in parallel', timeout: 1000) do |date|
|
106
|
+
# MyTable.where('created_at <= ?', date).count
|
107
|
+
# end
|
108
|
+
def initialize(*args, &block)
|
109
|
+
raise "Missing mandatory block that Minion must perform" unless block
|
110
|
+
@start_time = Time.now
|
111
|
+
@exception = nil
|
112
|
+
|
113
|
+
options = self.class.extract_options!(args).dup
|
114
|
+
|
115
|
+
@timeout = (options.delete(:timeout) || Minion::INFINITE).to_f
|
116
|
+
@description = (options.delete(:description) || 'Minion').to_s
|
117
|
+
@log_exception = options.delete(:log_exception)
|
118
|
+
@synchronous = options.delete(:synchronous) || self.class.synchronous?
|
119
|
+
|
120
|
+
# Warn about any unknown options.
|
121
|
+
options.each_pair { |key,val| logger.warn "Ignoring unknown option: #{key.inspect} => #{val.inspect}" }
|
122
|
+
|
123
|
+
# Run the supplied block of code in the current thread for testing or
|
124
|
+
# debugging purposes
|
125
|
+
if @synchronous == true
|
126
|
+
begin
|
127
|
+
logger.info("Started synchronously #{@description}")
|
128
|
+
logger.benchmark_info("Completed synchronously #{@description}", log_exception: @log_exception) do
|
129
|
+
@result = instance_exec(*args, &block)
|
130
|
+
end
|
131
|
+
rescue Exception => exc
|
132
|
+
@exception = exc
|
133
|
+
end
|
134
|
+
return
|
135
|
+
end
|
136
|
+
|
137
|
+
tags = (logger.tags || []).dup
|
138
|
+
|
139
|
+
# Copy current scopes for new thread. Only applicable for AR models
|
140
|
+
scopes = self.class.current_scopes.dup if defined?(ActiveRecord::Base)
|
141
|
+
|
142
|
+
@thread = Thread.new(*args) do
|
143
|
+
# Copy logging tags from parent thread
|
144
|
+
logger.tagged(*tags) do
|
145
|
+
# Set the current thread name to the description for this Minion
|
146
|
+
# so that all log entries in this thread use this thread name
|
147
|
+
Thread.current.name = "#{@description}-#{Thread.current.object_id}"
|
148
|
+
logger.info("Started #{@description}")
|
149
|
+
|
150
|
+
begin
|
151
|
+
logger.benchmark_info("Completed #{@description}", log_exception: @log_exception) do
|
152
|
+
# Use the current scope for the duration of the task execution
|
153
|
+
if scopes.nil? || (scopes.size == 0)
|
154
|
+
@result = instance_exec(*args, &block)
|
155
|
+
else
|
156
|
+
# Each Class to scope requires passing a block to .scoping
|
157
|
+
proc = Proc.new { instance_exec(*args, &block) }
|
158
|
+
first = scopes.shift
|
159
|
+
scopes.each {|scope| proc = Proc.new { scope.scoping(&proc) } }
|
160
|
+
@result = first.scoping(&proc)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
rescue Exception => exc
|
164
|
+
@exception = exc
|
165
|
+
nil
|
166
|
+
ensure
|
167
|
+
# Return any database connections used by this thread back to the pool
|
168
|
+
ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Returns the result when the thread completes
|
175
|
+
# Returns nil if the thread has not yet completed
|
176
|
+
# Raises any unhandled exception in the thread, if any
|
177
|
+
#
|
178
|
+
# Note: The result of any thread cannot be nil
|
179
|
+
def result
|
180
|
+
# Return nil if Minion is still working and has time left to finish
|
181
|
+
if working?
|
182
|
+
ms = time_left
|
183
|
+
return if @thread.join(ms.nil? ? nil: ms / 1000).nil?
|
184
|
+
end
|
185
|
+
|
186
|
+
# Return the exception, if any, otherwise the task result
|
187
|
+
exception.nil? ? @result : Kernel.raise(exception)
|
188
|
+
end
|
189
|
+
|
190
|
+
# Returns [Boolean] whether the minion is still working on the assigned task
|
191
|
+
def working?
|
192
|
+
synchronous? ? false : @thread.alive?
|
193
|
+
end
|
194
|
+
|
195
|
+
# Returns [Boolean] whether the minion has completed working on the task
|
196
|
+
def completed?
|
197
|
+
synchronous? ? true : @thread.stop?
|
198
|
+
end
|
199
|
+
|
200
|
+
# Returns [Boolean] whether the minion failed while performing the assigned task
|
201
|
+
def failed?
|
202
|
+
!exception.nil?
|
203
|
+
end
|
204
|
+
|
205
|
+
# Returns the amount of time left in milli-seconds that this Minion has to finish its task
|
206
|
+
# Returns 0 if no time is left
|
207
|
+
# Returns nil if their is no time limit. I.e. :timeout was set to Minion::INFINITE (infinite time left)
|
208
|
+
def time_left
|
209
|
+
return nil if @timeout == INFINITE
|
210
|
+
duration = @timeout - (Time.now - @start_time) * 1000
|
211
|
+
duration <= 0 ? 0 : duration
|
212
|
+
end
|
213
|
+
|
214
|
+
# Returns [Boolean] whether synchronous mode has been enabled for this minion instance
|
215
|
+
def synchronous?
|
216
|
+
@synchronous
|
217
|
+
end
|
218
|
+
|
219
|
+
# Returns the current scopes for each of the models for which scopes will be
|
220
|
+
# copied to the Minions
|
221
|
+
def self.current_scopes
|
222
|
+
# Apparently #scoped is deprecated, but its replacement #all does not behave the same
|
223
|
+
@@scoped_classes.collect {|klass| klass.scoped.dup}
|
224
|
+
end
|
225
|
+
|
226
|
+
protected
|
227
|
+
|
228
|
+
@@synchronous = false
|
229
|
+
@@scoped_classes = []
|
230
|
+
|
231
|
+
# Extract options from a hash.
|
232
|
+
def self.extract_options!(args)
|
233
|
+
args.last.is_a?(Hash) ? args.pop : {}
|
234
|
+
end
|
235
|
+
|
236
|
+
end
|
237
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ParallelMinion #:nodoc:
|
2
|
+
class Railtie < Rails::Railtie #:nodoc:
|
3
|
+
#
|
4
|
+
# Make the ParallelMinion config available in the Rails application config
|
5
|
+
#
|
6
|
+
# Example: Make debugging easier
|
7
|
+
# in file config/environments/development.rb
|
8
|
+
#
|
9
|
+
# Rails::Application.configure do
|
10
|
+
#
|
11
|
+
# # Run Minions in the current thread to make debugging easier
|
12
|
+
# config.parallel_minion.synchronous = true
|
13
|
+
#
|
14
|
+
# # Add a model so that its current scope is copied to the Minion
|
15
|
+
# config.parallel_minion.scoped_classes << MyScopedModel
|
16
|
+
# end
|
17
|
+
config.parallel_minion = ::ParallelMinion::Minion
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# Allow test to be run in-place without requiring a gem install
|
2
|
+
$LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
|
3
|
+
|
4
|
+
require 'semantic_logger'
|
5
|
+
# Register an appender if one is not already registered
|
6
|
+
SemanticLogger.default_level = :trace
|
7
|
+
SemanticLogger.add_appender('test.log', &SemanticLogger::Appender::Base.colorized_formatter) if SemanticLogger.appenders.size == 0
|
8
|
+
|
9
|
+
require 'rubygems'
|
10
|
+
require 'erb'
|
11
|
+
require 'test/unit'
|
12
|
+
# Since we want both the AR and Mongoid extensions loaded we need to require them first
|
13
|
+
require 'active_record'
|
14
|
+
require 'active_record/relation'
|
15
|
+
# Should redefines Proc#bind so must include after Rails
|
16
|
+
require 'shoulda'
|
17
|
+
require 'parallel_minion'
|
18
|
+
|
19
|
+
ActiveRecord::Base.logger = SemanticLogger[ActiveRecord]
|
20
|
+
ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read('test/config/database.yml')).result)
|
21
|
+
ActiveRecord::Base.establish_connection('test')
|
22
|
+
|
23
|
+
ActiveRecord::Schema.define :version => 0 do
|
24
|
+
create_table :people, :force => true do |t|
|
25
|
+
t.string :name
|
26
|
+
t.string :state
|
27
|
+
t.string :zip_code
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Person < ActiveRecord::Base
|
32
|
+
end
|
33
|
+
|
34
|
+
class MinionScopeTest < Test::Unit::TestCase
|
35
|
+
|
36
|
+
context ParallelMinion::Minion do
|
37
|
+
[false, true].each do |synchronous|
|
38
|
+
context ".new with synchronous: #{synchronous.inspect}" do
|
39
|
+
setup do
|
40
|
+
Person.create(name: 'Jack', state: 'FL', zip_code: 38729)
|
41
|
+
Person.create(name: 'John', state: 'FL', zip_code: 35363)
|
42
|
+
Person.create(name: 'Jill', state: 'FL', zip_code: 73534)
|
43
|
+
Person.create(name: 'Joe', state: 'NY', zip_code: 45325)
|
44
|
+
Person.create(name: 'Jane', state: 'NY', zip_code: 45325)
|
45
|
+
Person.create(name: 'James', state: 'CA', zip_code: 123123)
|
46
|
+
# Instruct Minions to adhere to any dynamic scopes for Person model
|
47
|
+
ParallelMinion::Minion.scoped_classes << Person
|
48
|
+
ParallelMinion::Minion.synchronous = synchronous
|
49
|
+
end
|
50
|
+
|
51
|
+
teardown do
|
52
|
+
Person.destroy_all
|
53
|
+
ParallelMinion::Minion.scoped_classes.clear
|
54
|
+
SemanticLogger.flush
|
55
|
+
end
|
56
|
+
|
57
|
+
should 'copy across model scope' do
|
58
|
+
assert_equal 6, Person.count
|
59
|
+
|
60
|
+
Person.unscoped.where(state: 'FL').scoping { Person.count }
|
61
|
+
|
62
|
+
Person.unscoped.where(state: 'FL').scoping do
|
63
|
+
assert_equal 3, Person.count
|
64
|
+
minion = ParallelMinion::Minion.new(description: 'Scope Test', log_exception: :full) do
|
65
|
+
Person.count
|
66
|
+
end
|
67
|
+
assert_equal 3, minion.result
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/test/minion_test.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
# Allow test to be run in-place without requiring a gem install
|
2
|
+
$LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
|
3
|
+
|
4
|
+
require 'test/unit'
|
5
|
+
require 'shoulda'
|
6
|
+
require 'parallel_minion'
|
7
|
+
|
8
|
+
# Register an appender if one is not already registered
|
9
|
+
SemanticLogger.default_level = :trace
|
10
|
+
SemanticLogger.add_appender('test.log', &SemanticLogger::Appender::Base.colorized_formatter) if SemanticLogger.appenders.size == 0
|
11
|
+
|
12
|
+
# Test ParallelMinion standalone without Rails
|
13
|
+
# Run this test standalone to verify it has no Rails dependencies
|
14
|
+
class MinionTest < Test::Unit::TestCase
|
15
|
+
include SemanticLogger::Loggable
|
16
|
+
|
17
|
+
context ParallelMinion::Minion do
|
18
|
+
|
19
|
+
[false, true].each do |synchronous|
|
20
|
+
context ".new with synchronous: #{synchronous.inspect}" do
|
21
|
+
setup do
|
22
|
+
ParallelMinion::Minion.synchronous = synchronous
|
23
|
+
end
|
24
|
+
|
25
|
+
should 'without parameters' do
|
26
|
+
minion = ParallelMinion::Minion.new { 196 }
|
27
|
+
assert_equal 196, minion.result
|
28
|
+
end
|
29
|
+
|
30
|
+
should 'with a description' do
|
31
|
+
minion = ParallelMinion::Minion.new(description: 'Test') { 197 }
|
32
|
+
assert_equal 197, minion.result
|
33
|
+
end
|
34
|
+
|
35
|
+
should 'with an argument' do
|
36
|
+
p1 = { name: 198 }
|
37
|
+
minion = ParallelMinion::Minion.new(p1, description: 'Test') do |v|
|
38
|
+
v[:name]
|
39
|
+
end
|
40
|
+
assert_equal 198, minion.result
|
41
|
+
end
|
42
|
+
|
43
|
+
should 'raise exception' do
|
44
|
+
minion = ParallelMinion::Minion.new(description: 'Test') { raise "An exception" }
|
45
|
+
assert_raise RuntimeError do
|
46
|
+
minion.result
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# TODO Blocks still have access to their original scope if variables cannot be
|
51
|
+
# resolved first by the parameters, then by the values in Minion itself
|
52
|
+
# should 'not have access to local variables' do
|
53
|
+
# name = 'Jack'
|
54
|
+
# minion = ParallelMinion::Minion.new(description: 'Test') { puts name }
|
55
|
+
# assert_raise NameError do
|
56
|
+
# minion.result
|
57
|
+
# end
|
58
|
+
# end
|
59
|
+
|
60
|
+
should 'run minion' do
|
61
|
+
hash = { value: 23 }
|
62
|
+
value = 47
|
63
|
+
minion = ParallelMinion::Minion.new(hash, description: 'Test') do |h|
|
64
|
+
value = 321
|
65
|
+
h[:value] = 123
|
66
|
+
456
|
67
|
+
end
|
68
|
+
assert_equal 456, minion.result
|
69
|
+
assert_equal 123, hash[:value]
|
70
|
+
assert_equal 321, value
|
71
|
+
end
|
72
|
+
|
73
|
+
should 'copy across logging tags' do
|
74
|
+
minion = nil
|
75
|
+
logger.tagged('TAG') do
|
76
|
+
assert_equal 'TAG', logger.tags.last
|
77
|
+
minion = ParallelMinion::Minion.new(description: 'Tag Test') do
|
78
|
+
logger.tags.last
|
79
|
+
end
|
80
|
+
end
|
81
|
+
assert_equal 'TAG', minion.result
|
82
|
+
end
|
83
|
+
|
84
|
+
should 'handle multiple minions concurrently' do
|
85
|
+
# Start 10 minions
|
86
|
+
minions = 10.times.collect do |i|
|
87
|
+
# Each Minion returns its index in the collection
|
88
|
+
ParallelMinion::Minion.new(i, description: "Minion:#{i}") {|counter| counter }
|
89
|
+
end
|
90
|
+
assert_equal 10, minions.count
|
91
|
+
# Fetch the result from each Minion
|
92
|
+
minions.each_with_index do |minion, index|
|
93
|
+
assert_equal index, minion.result
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
should 'timeout' do
|
98
|
+
minion = ParallelMinion::Minion.new(description: 'Test', timeout: 100) { sleep 1 }
|
99
|
+
# Only Parallel Minions time-out when they exceed timeout
|
100
|
+
unless synchronous
|
101
|
+
assert_equal nil, minion.result
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
end
|
Binary file
|
metadata
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: parallel_minion
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Reid Morrison
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-12-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: semantic_logger
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
description: Parallel Minion supports easily handing work off to Minions (Threads)
|
28
|
+
so that tasks that would normally be performed sequentially can easily be executed
|
29
|
+
in parallel
|
30
|
+
email:
|
31
|
+
- reidmo@gmail.com
|
32
|
+
executables: []
|
33
|
+
extensions: []
|
34
|
+
extra_rdoc_files: []
|
35
|
+
files:
|
36
|
+
- lib/parallel_minion/horde.rb
|
37
|
+
- lib/parallel_minion/minion.rb
|
38
|
+
- lib/parallel_minion/railtie.rb
|
39
|
+
- lib/parallel_minion/version.rb
|
40
|
+
- lib/parallel_minion.rb
|
41
|
+
- Rakefile
|
42
|
+
- README.md
|
43
|
+
- test/config/database.yml
|
44
|
+
- test/minion_scope_test.rb
|
45
|
+
- test/minion_test.rb
|
46
|
+
- test/test_db.sqlite3
|
47
|
+
homepage: https://github.com/ClarityServices/semantic_logger
|
48
|
+
licenses:
|
49
|
+
- Apache License V2.0
|
50
|
+
metadata: {}
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options: []
|
53
|
+
require_paths:
|
54
|
+
- lib
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - '>='
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '0'
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - '>='
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '0'
|
65
|
+
requirements: []
|
66
|
+
rubyforge_project:
|
67
|
+
rubygems_version: 2.1.11
|
68
|
+
signing_key:
|
69
|
+
specification_version: 4
|
70
|
+
summary: Concurrent processing made easy with Minions (Threads)
|
71
|
+
test_files:
|
72
|
+
- test/config/database.yml
|
73
|
+
- test/minion_scope_test.rb
|
74
|
+
- test/minion_test.rb
|
75
|
+
- test/test_db.sqlite3
|