simple_thread_pool 1.0.0 → 1.0.1
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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +11 -6
- data/VERSION +1 -1
- data/lib/simple_thread_pool.rb +108 -23
- data/simple_thread_pool.gemspec +27 -14
- metadata +12 -59
- data/.gitignore +0 -8
- data/CHANGE_LOG.md +0 -3
- data/Gemfile +0 -6
- data/Rakefile +0 -6
- /data/{LICENSE.txt → MIT_LICENSE.txt} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 199a48fe6964d0d8ffc112ee6c77da07f49e9111173d36549b6e9e9886a84584
|
|
4
|
+
data.tar.gz: 4b2d69ac3933cbe8b01ca9a8f4ada69bf29b775d8d465772f7434fa1f2a880c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 587d77f7a33035cfd359f7ba35496eddbcaad070c38244e333b2e4c653eddbe6973f67a346fdb100ad657b23e1e32ee94bc92ef1d91f93adfa3a2cd5a069882d
|
|
7
|
+
data.tar.gz: ed5801ac689401bef0caa1c66bf5813d8468cb4fca0d28fa3609312914ae195938a181168fa35cf8a06961bd10432fcb3f4b6bce5ea19943f3923db49f843a7b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## 1.0.1
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Threads finishing their work no longer call `Thread#wakeup` on the thread that scheduled them, which could interrupt unrelated `sleep` calls in application code.
|
|
13
|
+
- `finish` now waits for all threads even if a block raised an unhandled exception. Previously `Thread#join` could re-raise the block's exception, causing `finish` to raise and skip joining the remaining threads.
|
|
14
|
+
- Thread scheduling now waits on a condition variable instead of polling in a busy-wait loop, which also removes unsynchronized reads of internal state.
|
|
15
|
+
- An identifier is no longer permanently marked as processing if the thread for it could not be created.
|
|
16
|
+
- `SimpleThreadPool.new` now raises an `ArgumentError` if `max_threads` is less than 1 instead of hanging forever on `execute`.
|
|
17
|
+
- Fatal errors (non-StandardError) raised in threads will now be propagated to the calling thread either in the next call to `execute` or `finish`, instead of being silently swallowed. This allows the calling thread to handle the error appropriately.
|
|
18
|
+
- Removed unnecessary Redis dependency.
|
|
19
|
+
|
|
20
|
+
## 1.0.0
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Initial release
|
data/README.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Simple Thread Pool
|
|
2
|
+
|
|
3
|
+
[](https://github.com/bdurand/simple_thread_pool/actions/workflows/continuous_integration.yml)
|
|
4
|
+
[](https://github.com/testdouble/standard)
|
|
5
|
+
[](https://badge.fury.io/rb/simple_thread_pool)
|
|
2
6
|
|
|
3
7
|
Simple implementation of the thread pool to manage executing tasks in parallel. The thread pool implemented by this code is designed to allow throttled, parallel execution of tasks.
|
|
4
8
|
|
|
@@ -26,7 +30,7 @@ Or install it yourself as:
|
|
|
26
30
|
|
|
27
31
|
```ruby
|
|
28
32
|
# Create a pool of 10 threads to work with.
|
|
29
|
-
thread_pool =
|
|
33
|
+
thread_pool = SimpleThreadPool.new(10)
|
|
30
34
|
|
|
31
35
|
# Execute a block of code in a thread.
|
|
32
36
|
# If there are no free threads in the pool, then this will block until one is freed up.
|
|
@@ -35,8 +39,9 @@ thread_pool.execute do
|
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
# Execute a block of code with an identifier.
|
|
38
|
-
# The thread pool will not run code with the same identifier in parallel
|
|
39
|
-
#
|
|
42
|
+
# The thread pool will not run code with the same identifier in parallel.
|
|
43
|
+
# Blocks scheduled with the same identifier from a single thread will be
|
|
44
|
+
# executed in the order they are called.
|
|
40
45
|
thread_pool.execute("foo") do
|
|
41
46
|
# Do some work here
|
|
42
47
|
end
|
|
@@ -48,7 +53,7 @@ thread_pool.finish
|
|
|
48
53
|
|
|
49
54
|
### Error handling.
|
|
50
55
|
|
|
51
|
-
All error handling must be conducted inside the execute block. The main thread will not be notified of any exceptions. You can use the `
|
|
56
|
+
All error handling must be conducted inside the execute block. The main thread will not be notified of any exceptions. You can use the `synchronize` method on the thread pool if you need to work with data from the main thread.
|
|
52
57
|
|
|
53
58
|
Example of how you can track any errors in a shared array.
|
|
54
59
|
|
|
@@ -59,7 +64,7 @@ thread_pool.execute do
|
|
|
59
64
|
begin
|
|
60
65
|
# Do something
|
|
61
66
|
rescue Error => e
|
|
62
|
-
thread_pool.
|
|
67
|
+
thread_pool.synchronize { errors << e }
|
|
63
68
|
raise e
|
|
64
69
|
end
|
|
65
70
|
end
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.1
|
data/lib/simple_thread_pool.rb
CHANGED
|
@@ -1,85 +1,170 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'thread'
|
|
4
|
-
require 'set'
|
|
5
|
-
|
|
6
3
|
# Simple thread pool for executing blocks in parallel in a controlled manner.
|
|
7
4
|
# Threads are not re-used by the pool to prevent any thread local variables from
|
|
8
5
|
# leaking out.
|
|
9
6
|
class SimpleThreadPool
|
|
7
|
+
# Thread variable used to record the exception that terminated a worker thread.
|
|
8
|
+
WORKER_EXCEPTION = :simple_thread_pool_worker_exception
|
|
9
|
+
private_constant :WORKER_EXCEPTION
|
|
10
10
|
|
|
11
|
+
# @param max_threads [Integer] The maximum number of threads to spawn.
|
|
11
12
|
def initialize(max_threads)
|
|
13
|
+
raise ArgumentError, "max_threads must be at least 1" unless max_threads >= 1
|
|
12
14
|
@max_threads = max_threads
|
|
13
15
|
@lock = Mutex.new
|
|
16
|
+
@condition = ConditionVariable.new
|
|
14
17
|
@threads = []
|
|
15
18
|
@processing_ids = []
|
|
19
|
+
@fatal_exception = nil
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
# Call this method to spawn a thread to run the block. If the thread pool
|
|
19
23
|
# is already full, this method will block until a thread is free. The block
|
|
20
|
-
# is responsible for handling any
|
|
24
|
+
# is responsible for handling any StandardError that could be raised.
|
|
25
|
+
#
|
|
26
|
+
# If a block dies from an exception that is not a StandardError (i.e. an
|
|
27
|
+
# Exception that indicates the process itself is no longer healthy, such as
|
|
28
|
+
# NoMemoryError or SystemStackError), the pool is stopped. Blocks passed to
|
|
29
|
+
# this method after that point are not run; instead this method waits for the
|
|
30
|
+
# in-flight threads to finish and then raises the exception that stopped the
|
|
31
|
+
# pool, so that callers never silently lose work. The exception is cleared
|
|
32
|
+
# when it is raised, so it is delivered to exactly one caller and the pool can
|
|
33
|
+
# be used again afterwards.
|
|
21
34
|
#
|
|
22
35
|
# The optional id argument can be used to provide an identifier for a block.
|
|
23
36
|
# If one is provided, processing will be blocked if the same id is already
|
|
24
37
|
# being processed. This ensures that each unique id is executed one at a time
|
|
25
38
|
# sequentially.
|
|
39
|
+
#
|
|
40
|
+
# @param id [String, Symbol] An optional identifier for the block.
|
|
41
|
+
# @yield The block to execute in a thread.
|
|
42
|
+
# @return [void]
|
|
26
43
|
def execute(id = nil, &block)
|
|
27
44
|
loop do
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
stopped = @lock.synchronize do
|
|
46
|
+
until @fatal_exception || can_add_thread?(id)
|
|
47
|
+
@condition.wait(@lock)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if @fatal_exception
|
|
51
|
+
true
|
|
52
|
+
else
|
|
36
53
|
@processing_ids << id unless id.nil?
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
thread_added = false
|
|
55
|
+
begin
|
|
56
|
+
add_thread(id, block)
|
|
57
|
+
thread_added = true
|
|
58
|
+
ensure
|
|
59
|
+
unless thread_added
|
|
60
|
+
@processing_ids.delete(id) unless id.nil?
|
|
61
|
+
@condition.broadcast
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
false
|
|
39
65
|
end
|
|
40
66
|
end
|
|
67
|
+
|
|
68
|
+
break unless stopped
|
|
69
|
+
|
|
70
|
+
# The pool has been stopped. Drain the in-flight threads and raise the
|
|
71
|
+
# exception that stopped it. This has to happen outside of the synchronize
|
|
72
|
+
# block above because #finish acquires the same lock. If another caller
|
|
73
|
+
# claimed the exception first then #finish returns normally and the pool
|
|
74
|
+
# is usable again, so retry rather than silently dropping the block.
|
|
75
|
+
finish
|
|
41
76
|
end
|
|
77
|
+
|
|
78
|
+
nil
|
|
42
79
|
end
|
|
43
80
|
|
|
44
81
|
# Call this method to block until all current threads have finished executing.
|
|
82
|
+
#
|
|
83
|
+
# Exceptions raised by the blocks are not propagated; they are the block's
|
|
84
|
+
# responsibility to handle. The exception is that if a block died from an
|
|
85
|
+
# exception that was not a StandardError, that exception is re-raised here
|
|
86
|
+
# after every in-flight thread has finished. In-flight threads are always
|
|
87
|
+
# allowed to run to completion rather than being killed, so that they can run
|
|
88
|
+
# their own ensure blocks. That exception is cleared as it is raised, so it is
|
|
89
|
+
# delivered to exactly one caller and the pool can be used again afterwards.
|
|
90
|
+
#
|
|
91
|
+
# @return [void]
|
|
45
92
|
def finish
|
|
46
|
-
active_threads = @lock.synchronize
|
|
47
|
-
|
|
93
|
+
active_threads = @lock.synchronize do
|
|
94
|
+
# Exclude the calling thread so that this is safe to call from inside a
|
|
95
|
+
# block running in the pool; Thread#join raises if given the current
|
|
96
|
+
# thread. #execute calls this method, so that is reachable indirectly.
|
|
97
|
+
@threads.select { |thread| thread.alive? && !thread.equal?(Thread.current) }
|
|
98
|
+
end
|
|
99
|
+
active_threads.each do |thread|
|
|
100
|
+
thread.join
|
|
101
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
102
|
+
# Joining a thread that died with an unhandled exception re-raises that
|
|
103
|
+
# exception; those are the block's responsibility to handle, so they are
|
|
104
|
+
# not propagated here. Worker threads record the exception that killed
|
|
105
|
+
# them, so anything else caught here was raised on the calling thread
|
|
106
|
+
# (e.g. an Interrupt from a signal) and is always re-raised immediately so
|
|
107
|
+
# that the calling thread stays responsive to signals.
|
|
108
|
+
raise unless e.equal?(thread.thread_variable_get(WORKER_EXCEPTION))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Claim the exception under the lock and clear it before raising so that it
|
|
112
|
+
# is delivered to exactly one caller. The pool is usable again afterwards.
|
|
113
|
+
fatal_exception = @lock.synchronize do
|
|
114
|
+
exception = @fatal_exception
|
|
115
|
+
@fatal_exception = nil
|
|
116
|
+
exception
|
|
117
|
+
end
|
|
118
|
+
raise fatal_exception if fatal_exception
|
|
119
|
+
|
|
48
120
|
nil
|
|
49
121
|
end
|
|
50
122
|
|
|
51
123
|
# Synchronize data access across the thread pool. This method will block
|
|
52
124
|
# waiting on the same internal Mutex the thread pool uses to manage scheduling
|
|
53
125
|
# threads.
|
|
126
|
+
#
|
|
127
|
+
# @yield The block to execute in a synchronized manner.
|
|
128
|
+
# @return [Object] The return value of the block.
|
|
54
129
|
def synchronize(&block)
|
|
55
130
|
@lock.synchronize(&block)
|
|
56
131
|
end
|
|
57
|
-
|
|
132
|
+
|
|
58
133
|
private
|
|
59
|
-
|
|
134
|
+
|
|
60
135
|
def can_add_thread?(id)
|
|
61
136
|
@threads.size < @max_threads && (id.nil? || !@processing_ids.include?(id))
|
|
62
137
|
end
|
|
63
|
-
|
|
138
|
+
|
|
64
139
|
# Spawn a thread in this method to ensure that it doesn't accidentally pick up any local variables.
|
|
65
140
|
def add_thread(id, block)
|
|
66
|
-
main_thread = Thread.current
|
|
67
|
-
|
|
68
141
|
@threads << Thread.new do
|
|
142
|
+
fatal_exception = nil
|
|
69
143
|
begin
|
|
70
144
|
block.call
|
|
71
145
|
# Return nil to ensure no objects are leaked.
|
|
72
146
|
nil
|
|
147
|
+
rescue Exception => e # standard:disable Lint/RescueException
|
|
148
|
+
# Record the exception so that #finish can tell an exception re-raised
|
|
149
|
+
# by Thread#join apart from one raised on the calling thread. It is
|
|
150
|
+
# re-raised so the normal Thread.report_on_exception behavior is kept.
|
|
151
|
+
Thread.current.thread_variable_set(WORKER_EXCEPTION, e)
|
|
152
|
+
# Anything that isn't a StandardError means the process is no longer
|
|
153
|
+
# healthy, so it stops the pool instead of being left to the block.
|
|
154
|
+
fatal_exception = e unless e.is_a?(StandardError)
|
|
155
|
+
raise
|
|
73
156
|
ensure
|
|
74
157
|
@lock.synchronize do
|
|
158
|
+
# Set under the same lock as the broadcast so that callers waiting in
|
|
159
|
+
# #execute see the stopped pool as soon as they wake up.
|
|
160
|
+
@fatal_exception ||= fatal_exception
|
|
75
161
|
@processing_ids.delete(id) unless id.nil?
|
|
76
162
|
@threads.delete(Thread.current)
|
|
163
|
+
@condition.broadcast
|
|
77
164
|
end
|
|
78
|
-
main_thread.wakeup if main_thread.alive?
|
|
79
165
|
end
|
|
80
166
|
end
|
|
81
167
|
|
|
82
168
|
nil
|
|
83
169
|
end
|
|
84
|
-
|
|
85
170
|
end
|
data/simple_thread_pool.gemspec
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
Gem::Specification.new do |spec|
|
|
2
|
-
spec.name
|
|
3
|
-
spec.version
|
|
4
|
-
spec.authors
|
|
5
|
-
spec.email
|
|
2
|
+
spec.name = "simple_thread_pool"
|
|
3
|
+
spec.version = File.read(File.expand_path("VERSION", __dir__)).strip
|
|
4
|
+
spec.authors = ["Brian Durand"]
|
|
5
|
+
spec.email = ["bbdurand@gmail.com"]
|
|
6
6
|
|
|
7
|
-
spec.summary
|
|
8
|
-
spec.homepage
|
|
9
|
-
spec.license
|
|
7
|
+
spec.summary = "Simple thread pool implementation to manage running tasks in parallel."
|
|
8
|
+
spec.homepage = "https://github.com/bdurand/simple_thread_pool"
|
|
9
|
+
spec.license = "MIT"
|
|
10
|
+
|
|
11
|
+
spec.metadata = {
|
|
12
|
+
"homepage_uri" => spec.homepage,
|
|
13
|
+
"source_code_uri" => spec.homepage,
|
|
14
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
# Specify which files should be added to the gem when it is released.
|
|
12
18
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
ignore_files = %w[
|
|
20
|
+
.
|
|
21
|
+
Appraisals
|
|
22
|
+
Gemfile
|
|
23
|
+
Gemfile.lock
|
|
24
|
+
Rakefile
|
|
25
|
+
bin/
|
|
26
|
+
gemfiles/
|
|
27
|
+
spec/
|
|
28
|
+
]
|
|
29
|
+
spec.files = Dir.chdir(__dir__) do
|
|
30
|
+
`git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
|
|
15
31
|
end
|
|
16
|
-
|
|
17
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
32
|
+
|
|
18
33
|
spec.require_paths = ["lib"]
|
|
19
34
|
|
|
20
|
-
spec.
|
|
21
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
|
22
|
-
spec.add_development_dependency "rspec", "~> 3.8"
|
|
35
|
+
spec.required_ruby_version = ">= 2.6"
|
|
23
36
|
end
|
metadata
CHANGED
|
@@ -1,78 +1,33 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: simple_thread_pool
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
8
|
-
|
|
9
|
-
bindir: exe
|
|
8
|
+
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
12
|
-
dependencies:
|
|
13
|
-
- !ruby/object:Gem::Dependency
|
|
14
|
-
name: bundler
|
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
|
16
|
-
requirements:
|
|
17
|
-
- - "~>"
|
|
18
|
-
- !ruby/object:Gem::Version
|
|
19
|
-
version: '1.16'
|
|
20
|
-
type: :development
|
|
21
|
-
prerelease: false
|
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
-
requirements:
|
|
24
|
-
- - "~>"
|
|
25
|
-
- !ruby/object:Gem::Version
|
|
26
|
-
version: '1.16'
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: rake
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - "~>"
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '10.0'
|
|
34
|
-
type: :development
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - "~>"
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '10.0'
|
|
41
|
-
- !ruby/object:Gem::Dependency
|
|
42
|
-
name: rspec
|
|
43
|
-
requirement: !ruby/object:Gem::Requirement
|
|
44
|
-
requirements:
|
|
45
|
-
- - "~>"
|
|
46
|
-
- !ruby/object:Gem::Version
|
|
47
|
-
version: '3.8'
|
|
48
|
-
type: :development
|
|
49
|
-
prerelease: false
|
|
50
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
-
requirements:
|
|
52
|
-
- - "~>"
|
|
53
|
-
- !ruby/object:Gem::Version
|
|
54
|
-
version: '3.8'
|
|
55
|
-
description:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
56
12
|
email:
|
|
57
13
|
- bbdurand@gmail.com
|
|
58
14
|
executables: []
|
|
59
15
|
extensions: []
|
|
60
16
|
extra_rdoc_files: []
|
|
61
17
|
files:
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
- Gemfile
|
|
65
|
-
- LICENSE.txt
|
|
18
|
+
- CHANGELOG.md
|
|
19
|
+
- MIT_LICENSE.txt
|
|
66
20
|
- README.md
|
|
67
|
-
- Rakefile
|
|
68
21
|
- VERSION
|
|
69
22
|
- lib/simple_thread_pool.rb
|
|
70
23
|
- simple_thread_pool.gemspec
|
|
71
24
|
homepage: https://github.com/bdurand/simple_thread_pool
|
|
72
25
|
licenses:
|
|
73
26
|
- MIT
|
|
74
|
-
metadata:
|
|
75
|
-
|
|
27
|
+
metadata:
|
|
28
|
+
homepage_uri: https://github.com/bdurand/simple_thread_pool
|
|
29
|
+
source_code_uri: https://github.com/bdurand/simple_thread_pool
|
|
30
|
+
changelog_uri: https://github.com/bdurand/simple_thread_pool/blob/main/CHANGELOG.md
|
|
76
31
|
rdoc_options: []
|
|
77
32
|
require_paths:
|
|
78
33
|
- lib
|
|
@@ -80,16 +35,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
80
35
|
requirements:
|
|
81
36
|
- - ">="
|
|
82
37
|
- !ruby/object:Gem::Version
|
|
83
|
-
version: '
|
|
38
|
+
version: '2.6'
|
|
84
39
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
40
|
requirements:
|
|
86
41
|
- - ">="
|
|
87
42
|
- !ruby/object:Gem::Version
|
|
88
43
|
version: '0'
|
|
89
44
|
requirements: []
|
|
90
|
-
|
|
91
|
-
rubygems_version: 2.7.6
|
|
92
|
-
signing_key:
|
|
45
|
+
rubygems_version: 4.0.3
|
|
93
46
|
specification_version: 4
|
|
94
47
|
summary: Simple thread pool implementation to manage running tasks in parallel.
|
|
95
48
|
test_files: []
|
data/.gitignore
DELETED
data/CHANGE_LOG.md
DELETED
data/Gemfile
DELETED
data/Rakefile
DELETED
|
File without changes
|