sucker_punch 1.6.0 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,25 +1,68 @@
1
1
  module SuckerPunch
2
+ # Include this module in your job class
3
+ # to create asynchronous jobs:
4
+ #
5
+ # class LogJob
6
+ # include SuckerPunch::Job
7
+ # workers 4
8
+ #
9
+ # def perform(*args)
10
+ # # log the things
11
+ # end
12
+ # end
13
+ #
14
+ # To trigger asynchronous job:
15
+ #
16
+ # LogJob.perform_async(1, 2, 3)
17
+ # LogJob.perform_in(60, 1, 2, 3) # `perform` will be excuted 60 sec. later
18
+ #
19
+ # Note that perform_async is a class method, perform is an instance method.
2
20
  module Job
3
21
  def self.included(base)
4
- base.send(:include, ::Celluloid)
5
22
  base.extend(ClassMethods)
23
+ base.class_attribute :num_workers
6
24
 
7
- base.class_eval do
8
- def self.new
9
- define_celluloid_pool(self, @workers)
10
- end
11
- end
25
+ base.num_workers = 2
26
+ end
27
+
28
+ def logger
29
+ SuckerPunch.logger
12
30
  end
13
31
 
14
32
  module ClassMethods
33
+ def perform_async(*args)
34
+ return unless SuckerPunch::RUNNING.true?
35
+ queue = SuckerPunch::Queue.find_or_create(self.to_s, num_workers)
36
+ queue.post(args) { |args| __run_perform(*args) }
37
+ end
38
+
39
+ def perform_in(interval, *args)
40
+ return unless SuckerPunch::RUNNING.true?
41
+ queue = SuckerPunch::Queue.find_or_create(self.to_s, num_workers)
42
+ job = Concurrent::ScheduledTask.execute(interval.to_f, args: args, executor: queue) do
43
+ __run_perform(*args)
44
+ end
45
+ job.pending?
46
+ end
47
+
15
48
  def workers(num)
16
- @workers = num
49
+ self.num_workers = num
17
50
  end
18
51
 
19
- def define_celluloid_pool(klass, num_workers)
20
- SuckerPunch::Queue.new(klass).register(num_workers)
52
+ def __run_perform(*args)
53
+ # break if shutdown began while I was waiting in the queue
54
+ return unless SuckerPunch::RUNNING.true?
55
+
56
+ SuckerPunch::Counter::Busy.new(self.to_s).increment
57
+ result = self.new.perform(*args)
58
+ SuckerPunch::Counter::Processed.new(self.to_s).increment
59
+ result
60
+ rescue => ex
61
+ SuckerPunch::Counter::Failed.new(self.to_s).increment
62
+ SuckerPunch.exception_handler.call(ex, self, args)
63
+ ensure
64
+ SuckerPunch::Counter::Busy.new(self.to_s).decrement
21
65
  end
22
66
  end
23
-
24
67
  end
25
68
  end
@@ -1,57 +1,182 @@
1
- require 'thread'
1
+ require 'forwardable'
2
2
 
3
3
  module SuckerPunch
4
- class Queue
5
- attr_reader :klass
4
+ class Queue < Concurrent::Synchronization::LockableObject
5
+ extend Forwardable
6
+ include Concurrent::ExecutorService
6
7
 
7
- DEFAULT_OPTIONS = { workers: 2 }
8
- PREFIX = "sucker_punch"
9
- class MaxWorkersExceeded < StandardError; end
10
- class NotEnoughWorkers < StandardError; end
8
+ DEFAULT_EXECUTOR_OPTIONS = {
9
+ min_threads: 2,
10
+ max_threads: 2,
11
+ idletime: 60, # 1 minute
12
+ max_queue: 0, # unlimited
13
+ auto_terminate: false # Let shutdown modes handle thread termination
14
+ }.freeze
11
15
 
12
- def self.find(klass)
13
- queue = self.new(klass)
14
- Celluloid::Actor[queue.name]
16
+ QUEUES = Concurrent::Map.new
17
+
18
+ def self.find_or_create(name, num_workers = 2)
19
+ pool = QUEUES.fetch_or_store(name) do
20
+ options = DEFAULT_EXECUTOR_OPTIONS.merge({
21
+ min_threads: num_workers,
22
+ max_threads: num_workers
23
+ })
24
+ Concurrent::ThreadPoolExecutor.new(options)
25
+ end
26
+
27
+ new(name, pool)
28
+ end
29
+
30
+ def self.all
31
+ queues = Concurrent::Array.new
32
+ QUEUES.each_pair do |name, pool|
33
+ queues.push new(name, pool)
34
+ end
35
+ queues
15
36
  end
16
37
 
17
- def initialize(klass)
18
- @klass = klass
19
- @mutex = Mutex.new
38
+ def self.clear
39
+ # susceptible to race conditions--only use in testing
40
+ old = all
41
+ QUEUES.clear
42
+ SuckerPunch::Counter::Busy.clear
43
+ SuckerPunch::Counter::Processed.clear
44
+ SuckerPunch::Counter::Failed.clear
45
+ old.each { |queue| queue.kill }
46
+ end
47
+
48
+ def self.stats
49
+ queues = {}
50
+
51
+ all.each do |queue|
52
+ queues[queue.name] = {
53
+ "workers" => {
54
+ "total" => queue.total_workers,
55
+ "busy" => queue.busy_workers,
56
+ "idle" => queue.idle_workers,
57
+ },
58
+ "jobs" => {
59
+ "processed" => queue.processed_jobs,
60
+ "failed" => queue.failed_jobs,
61
+ "enqueued" => queue.enqueued_jobs,
62
+ }
63
+ }
64
+ end
65
+
66
+ queues
20
67
  end
21
68
 
22
- def register(num_workers = DEFAULT_OPTIONS[:workers])
23
- num_workers ||= DEFAULT_OPTIONS[:workers]
24
- raise MaxWorkersExceeded if num_workers > 200
25
- raise NotEnoughWorkers if num_workers < 1
69
+ def self.shutdown_all
70
+ if SuckerPunch::RUNNING.make_false
71
+ SuckerPunch.logger.info("Shutdown triggered...executing remaining in-process jobs")
26
72
 
27
- @mutex.synchronize {
28
- unless registered?
29
- initialize_celluloid_pool(num_workers)
73
+ queues = all
74
+ latch = Concurrent::CountDownLatch.new(queues.length)
75
+
76
+ queues.each do |queue|
77
+ queue.post(latch) { |l| l.count_down }
78
+ queue.shutdown
79
+ end
80
+
81
+ if latch.wait(SuckerPunch.shutdown_timeout)
82
+ SuckerPunch.logger.info("Remaining jobs have finished")
83
+ else
84
+ queues.each { |queue| queue.kill }
85
+ SuckerPunch.logger.info("Remaining jobs didn't finish in time...killing remaining jobs")
30
86
  end
31
- }
32
- self.class.find(klass)
87
+ end
88
+ end
89
+
90
+ attr_reader :name
91
+
92
+ def_delegators :@pool,
93
+ :max_length,
94
+ :min_length,
95
+ :length,
96
+ :queue_length#,
97
+ #:idletime,
98
+ #:max_queue,
99
+ #:largest_length,
100
+ #:scheduled_task_count,
101
+ #:completed_task_count,
102
+ #:can_overflow?,
103
+ #:remaining_capacity,
104
+ #:running?,
105
+ #:shuttingdown?
106
+
107
+ alias_method :total_workers, :length
108
+ alias_method :enqueued_jobs, :queue_length
109
+
110
+ def initialize(name, pool)
111
+ super()
112
+ @running = true
113
+ @name, @pool = name, pool
114
+ end
115
+
116
+ def running?
117
+ synchronize { @running }
33
118
  end
34
119
 
35
- def registered?
36
- Celluloid::Actor.registered.include?(name.to_sym)
120
+ def ==(other)
121
+ pool == other.pool
37
122
  end
38
123
 
39
- def name
40
- klass_name = klass.to_s.underscore
41
- "#{PREFIX}_#{klass_name}".to_sym
124
+ def busy_workers
125
+ SuckerPunch::Counter::Busy.new(name).value
126
+ end
127
+
128
+ def idle_workers
129
+ total_workers - busy_workers
130
+ end
131
+
132
+ def processed_jobs
133
+ SuckerPunch::Counter::Processed.new(name).value
134
+ end
135
+
136
+ def failed_jobs
137
+ SuckerPunch::Counter::Failed.new(name).value
138
+ end
139
+
140
+ def post(*args, &block)
141
+ synchronize do
142
+ if @running
143
+ @pool.post(*args, &block)
144
+ else
145
+ false
146
+ end
147
+ end
148
+ end
149
+
150
+ def kill
151
+ if can_initiate_shutdown?
152
+ @pool.kill
153
+ end
154
+ end
155
+
156
+ def shutdown
157
+ if can_initiate_shutdown?
158
+ @pool.shutdown
159
+ end
160
+ end
161
+
162
+ protected
163
+
164
+ def pool
165
+ @pool
42
166
  end
43
167
 
44
168
  private
45
169
 
46
- def initialize_celluloid_pool(num_workers)
47
- pool_class = klass
48
- pool_name = name
49
- pool = Class.new(Celluloid::Supervision::Container) do
50
- pool pool_class, as: pool_name, size: num_workers
170
+ def can_initiate_shutdown?
171
+ synchronize do
172
+ if @running
173
+ @running = false
174
+ true
175
+ else
176
+ false
177
+ end
51
178
  end
52
- pool.run!
53
179
  end
54
180
  end
55
181
  end
56
182
 
57
-
@@ -1,11 +1,37 @@
1
1
  require 'sucker_punch'
2
- require 'celluloid/proxy/abstract'
3
- require 'celluloid/proxy/sync'
4
- require 'celluloid/proxy/actor'
5
2
 
6
- class Celluloid::Proxy::Cell < Celluloid::Proxy::Sync
7
- def async(method_name = nil, *args, &block)
8
- self
3
+ # Include this in your tests to simulate
4
+ # immediate execution of your asynchronous jobs
5
+ #
6
+ # class LogJob
7
+ # include SuckerPunch::Job
8
+ #
9
+ # def perform(*args)
10
+ # # log the things
11
+ # end
12
+ # end
13
+ #
14
+ # To trigger asynchronous job:
15
+ #
16
+ # LogJob.perform_async(1, 2, 3)
17
+ #
18
+ # Include inline testing lib:
19
+ #
20
+ # require 'sucker_punch/testing/inline"
21
+ #
22
+ # LogJob.perform_async(1, 2, 3) is now synchronous
23
+ # LogJob.perform_in(1, 2, 3) is now synchronous
24
+ #
25
+ module SuckerPunch
26
+ module Job
27
+ module ClassMethods
28
+ def perform_async(*args)
29
+ self.new.perform(*args)
30
+ end
31
+
32
+ def perform_in(_, *args)
33
+ self.new.perform(*args)
34
+ end
35
+ end
9
36
  end
10
37
  end
11
-
@@ -1,3 +1,3 @@
1
1
  module SuckerPunch
2
- VERSION = "1.6.0"
2
+ VERSION = "2.0.0.beta1"
3
3
  end
data/lib/sucker_punch.rb CHANGED
@@ -1,21 +1,58 @@
1
- require 'celluloid/current'
1
+ require 'concurrent'
2
2
  require 'sucker_punch/core_ext'
3
+ require 'sucker_punch/counter'
3
4
  require 'sucker_punch/job'
4
5
  require 'sucker_punch/queue'
5
6
  require 'sucker_punch/version'
7
+ require 'logger'
6
8
 
7
9
  module SuckerPunch
8
- def self.logger
9
- Celluloid.logger
10
- end
10
+ RUNNING = Concurrent::AtomicBoolean.new(true)
11
11
 
12
- def self.logger=(logger)
13
- Celluloid.logger = logger
14
- end
12
+ class << self
13
+ def exception_handler=(handler)
14
+ @exception_handler = handler
15
+ end
16
+
17
+ def exception_handler
18
+ @exception_handler || method(:default_exception_handler)
19
+ end
20
+
21
+ def default_exception_handler(ex, klass, args)
22
+ msg = "Sucker Punch job error for class: '#{klass}' args: #{args}\n"
23
+ msg += "#{ex.class} #{ex}\n"
24
+ msg += "#{ex.backtrace.nil? ? '' : ex.backtrace.join("\n")}"
25
+ logger.error msg
26
+ end
27
+
28
+ def logger
29
+ @logger || default_logger
30
+ end
15
31
 
16
- def self.exception_handler(&block)
17
- Celluloid.exception_handler(&block)
32
+ def logger=(log)
33
+ @logger = (log ? log : Logger.new('/dev/null'))
34
+ end
35
+
36
+ def default_logger
37
+ l = Logger.new(STDOUT)
38
+ l.level = Logger::INFO
39
+ l
40
+ end
41
+
42
+ def shutdown_timeout
43
+ # 10 seconds on heroku, minus a grace period
44
+ @shutdown_timeout || 8
45
+ end
46
+
47
+ def shutdown_timeout=(timeout)
48
+ @shutdown_timeout = timeout
49
+ end
18
50
  end
19
51
  end
20
52
 
53
+ at_exit do
54
+ SuckerPunch::Queue.shutdown_all
55
+ SuckerPunch.logger.info("All is quiet...byebye")
56
+ end
57
+
21
58
  require 'sucker_punch/railtie' if defined?(::Rails)
data/sucker_punch.gemspec CHANGED
@@ -18,9 +18,11 @@ Gem::Specification.new do |gem|
18
18
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
19
  gem.require_paths = ["lib"]
20
20
 
21
- gem.add_development_dependency "rspec"
22
- gem.add_development_dependency "rake"
21
+ gem.post_install_message = "Sucker Punch Version 2.0 introduces backwards-incompatible changes. Please see https://github.com/brandonhilkert/sucker_punch/blob/master/CHANGES.md#20 for details."
22
+
23
+ gem.add_development_dependency "rake", "~> 10.0"
24
+ gem.add_development_dependency "minitest"
23
25
  gem.add_development_dependency "pry"
24
26
 
25
- gem.add_dependency "celluloid", "~> 0.17.2"
27
+ gem.add_dependency "concurrent-ruby", "~> 1.0.0"
26
28
  end
@@ -0,0 +1,33 @@
1
+ require 'test_helper'
2
+
3
+ module SuckerPunch
4
+ class AsyncSyntaxTest < Minitest::Test
5
+ def setup
6
+ require 'sucker_punch/async_syntax'
7
+ SuckerPunch::Queue.clear
8
+ end
9
+
10
+ def teardown
11
+ SuckerPunch::Queue.clear
12
+ end
13
+
14
+ def test_perform_async_runs_job_asynchronously
15
+ arr = Concurrent::Array.new
16
+ latch = Concurrent::CountDownLatch.new
17
+ FakeLatchJob.new.async.perform(arr, latch)
18
+ latch.wait(0.2)
19
+ assert_equal 1, arr.size
20
+ end
21
+
22
+ private
23
+
24
+ class FakeLatchJob
25
+ include SuckerPunch::Job
26
+
27
+ def perform(arr, latch)
28
+ arr.push true
29
+ latch.count_down
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,83 @@
1
+ require 'test_helper'
2
+
3
+ module SuckerPunch
4
+ class CounterTest < Minitest::Test
5
+ def setup
6
+ @queue = "fake"
7
+ SuckerPunch::Queue.clear
8
+ end
9
+
10
+ def teardown
11
+ SuckerPunch::Queue.clear
12
+ SuckerPunch::Counter::Busy.clear
13
+ SuckerPunch::Counter::Failed.clear
14
+ SuckerPunch::Counter::Processed.clear
15
+ end
16
+
17
+ def test_busy_counter_can_be_cleared
18
+ SuckerPunch::Counter::Busy.new(@queue).increment
19
+ SuckerPunch::Counter::Busy.clear
20
+ assert_equal 0, SuckerPunch::Counter::Busy.new(@queue).value
21
+ end
22
+
23
+ def test_same_busy_counter_is_returned
24
+ c = SuckerPunch::Counter::Busy.new(@queue)
25
+ assert_equal c.counter, SuckerPunch::Counter::Busy::COUNTER[@queue]
26
+ end
27
+
28
+ def test_busy_counter_default_is_0
29
+ c = SuckerPunch::Counter::Busy.new(@queue)
30
+ assert_equal 0, c.value
31
+ end
32
+
33
+ def test_busy_counter_supports_incrementing_and_decrementing
34
+ c = SuckerPunch::Counter::Busy.new(@queue)
35
+ assert_equal 1, c.increment
36
+ assert_equal 0, c.decrement
37
+ end
38
+
39
+ def test_processed_counter_can_be_cleared
40
+ SuckerPunch::Counter::Processed.new(@queue).increment
41
+ SuckerPunch::Counter::Processed.clear
42
+ assert_equal 0, SuckerPunch::Counter::Processed.new(@queue).value
43
+ end
44
+
45
+ def test_same_counter_is_returned_for_processed
46
+ c = SuckerPunch::Counter::Processed.new(@queue)
47
+ assert_equal c.counter, SuckerPunch::Counter::Processed::COUNTER[@queue]
48
+ end
49
+
50
+ def test_processed_counter_default_is_0
51
+ c = SuckerPunch::Counter::Processed.new(@queue)
52
+ assert_equal 0, c.value
53
+ end
54
+
55
+ def test_processed_counter_supports_incrementing_and_decrementing
56
+ c = SuckerPunch::Counter::Processed.new(@queue)
57
+ assert_equal 1, c.increment
58
+ assert_equal 0, c.decrement
59
+ end
60
+
61
+ def test_failed_counter_can_be_cleared
62
+ SuckerPunch::Counter::Failed.new(@queue).increment
63
+ SuckerPunch::Counter::Failed.clear
64
+ assert_equal 0, SuckerPunch::Counter::Failed.new(@queue).value
65
+ end
66
+
67
+ def test_same_counter_is_returned_for_failed
68
+ c = SuckerPunch::Counter::Failed.new(@queue)
69
+ assert_equal c.counter, SuckerPunch::Counter::Failed::COUNTER[@queue]
70
+ end
71
+
72
+ def test_failed_counter_default_is_0
73
+ c = SuckerPunch::Counter::Failed.new(@queue)
74
+ assert_equal 0, c.value
75
+ end
76
+
77
+ def test_failed_counter_supports_incrementing_and_decrementing
78
+ c = SuckerPunch::Counter::Failed.new(@queue)
79
+ assert_equal 1, c.increment
80
+ assert_equal 0, c.decrement
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,157 @@
1
+ require 'test_helper'
2
+
3
+ module SuckerPunch
4
+ class JobTest < Minitest::Test
5
+ def setup
6
+ SuckerPunch::Queue.clear
7
+ end
8
+
9
+ def teardown
10
+ SuckerPunch::Queue.clear
11
+ SuckerPunch::RUNNING.make_true
12
+ end
13
+
14
+ def test_perform_async_runs_job_asynchronously
15
+ arr = Concurrent::Array.new
16
+ latch = Concurrent::CountDownLatch.new
17
+ FakeLatchJob.perform_async(arr, latch)
18
+ latch.wait(0.2)
19
+ assert_equal 1, arr.size
20
+ end
21
+
22
+ def test_job_isnt_run_with_perform_async_if_sucker_punch_is_shutdown
23
+ SuckerPunch::RUNNING.make_false
24
+ arr = Concurrent::Array.new
25
+ latch = Concurrent::CountDownLatch.new
26
+ FakeLatchJob.perform_async(arr, latch)
27
+ latch.wait(0.2)
28
+ assert_equal 0, arr.size
29
+ end
30
+
31
+ def test_perform_in_runs_job_in_future
32
+ arr = Concurrent::Array.new
33
+ latch = Concurrent::CountDownLatch.new
34
+ FakeLatchJob.perform_in(0.1, arr, latch)
35
+ latch.wait(0.2)
36
+ assert_equal 1, arr.size
37
+ end
38
+
39
+ def test_job_isnt_run_with_perform_in_if_sucker_punch_is_shutdown
40
+ SuckerPunch::RUNNING.make_false
41
+ arr = Concurrent::Array.new
42
+ latch = Concurrent::CountDownLatch.new
43
+ FakeLatchJob.perform_in(0.1, arr, latch)
44
+ latch.wait(0.2)
45
+ assert_equal 0, arr.size
46
+ end
47
+
48
+ def test_default_workers_is_2
49
+ assert_equal 2, FakeLogJob.num_workers
50
+ end
51
+
52
+ def test_can_set_workers_count
53
+ FakeLogJob.workers(4)
54
+ assert_equal 4, FakeLogJob.num_workers
55
+ FakeLogJob.workers(2)
56
+ end
57
+
58
+ def test_logger_is_accessible_from_instance
59
+ SuckerPunch.logger = SuckerPunch.default_logger
60
+ assert_equal SuckerPunch.logger, FakeLogJob.new.logger
61
+ SuckerPunch.logger = nil
62
+ end
63
+
64
+ def test_num_workers_can_be_set_by_worker_method
65
+ assert_equal 4, FakeWorkerJob.num_workers
66
+ end
67
+
68
+ def test_num_workers_is_set_when_enqueueing_job_immediately
69
+ FakeWorkerJob.perform_async
70
+ pool = SuckerPunch::Queue::QUEUES[FakeWorkerJob.to_s]
71
+ assert_equal 4, pool.max_length
72
+ assert_equal 4, pool.min_length
73
+ end
74
+
75
+ def test_num_workers_is_set_when_enqueueing_job_in_future
76
+ FakeWorkerJob.perform_in(30)
77
+ pool = SuckerPunch::Queue::QUEUES[FakeWorkerJob.to_s]
78
+ assert_equal 4, pool.max_length
79
+ assert_equal 4, pool.min_length
80
+ end
81
+
82
+ def test_run_perform_delegates_to_instance_perform
83
+ assert_equal "fake", FakeLogJob.__run_perform
84
+ end
85
+
86
+ def test_busy_workers_is_incremented_during_job_execution
87
+ FakeSlowJob.perform_async
88
+ sleep 0.1
89
+ assert SuckerPunch::Counter::Busy.new(FakeSlowJob.to_s).value > 0
90
+ end
91
+
92
+ def test_processed_jobs_is_incremented_on_successful_completion
93
+ latch = Concurrent::CountDownLatch.new
94
+ 2.times{ FakeLogJob.perform_async }
95
+ queue = SuckerPunch::Queue.find_or_create(FakeLogJob.to_s)
96
+ queue.post { latch.count_down }
97
+ latch.wait(0.2)
98
+ assert SuckerPunch::Counter::Processed.new(FakeLogJob.to_s).value > 0
99
+ end
100
+
101
+ def test_processed_jobs_is_incremented_when_enqueued_with_perform_in
102
+ latch = Concurrent::CountDownLatch.new
103
+ FakeLatchJob.perform_in(0.1, [], latch)
104
+ latch.wait(0.2)
105
+ assert SuckerPunch::Counter::Processed.new(FakeLatchJob.to_s).value > 0
106
+ end
107
+
108
+ def test_failed_jobs_is_incremented_when_job_raises
109
+ latch = Concurrent::CountDownLatch.new
110
+ 2.times{ FakeErrorJob.perform_async }
111
+ queue = SuckerPunch::Queue.find_or_create(FakeErrorJob.to_s)
112
+ queue.post { latch.count_down }
113
+ latch.wait(0.2)
114
+ assert SuckerPunch::Counter::Failed.new(FakeErrorJob.to_s).value > 0
115
+ end
116
+
117
+ private
118
+
119
+ class FakeLatchJob
120
+ include SuckerPunch::Job
121
+ def perform(arr, latch)
122
+ arr.push true
123
+ latch.count_down
124
+ end
125
+ end
126
+
127
+ class FakeSlowJob
128
+ include SuckerPunch::Job
129
+ def perform
130
+ sleep 0.3
131
+ end
132
+ end
133
+
134
+ class FakeLogJob
135
+ include SuckerPunch::Job
136
+ def perform
137
+ "fake"
138
+ end
139
+ end
140
+
141
+ class FakeWorkerJob
142
+ include SuckerPunch::Job
143
+ workers 4
144
+ def perform
145
+ "fake"
146
+ end
147
+ end
148
+
149
+ class FakeErrorJob
150
+ include SuckerPunch::Job
151
+ def perform
152
+ raise "error"
153
+ end
154
+ end
155
+ end
156
+ end
157
+