sucker_punch 1.6.0 → 2.0.0.beta1

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.
@@ -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
+