shoryuken-later 0.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.
@@ -0,0 +1,42 @@
1
+ # All of this has been "borrowed" from Shoryuken.
2
+
3
+ # @see Shoryuken::Launcher
4
+ module Shoryuken
5
+ module Later
6
+ class Launcher
7
+ include Celluloid
8
+ include Shoryuken::Util
9
+
10
+ trap_exit :actor_died
11
+
12
+ attr_accessor :manager
13
+
14
+ def initialize
15
+ @manager = Shoryuken::Later::Manager.new_link
16
+
17
+ @done = false
18
+ end
19
+
20
+ def stop(options = {})
21
+ watchdog('Later::Launcher#stop') do
22
+ @done = true
23
+
24
+ manager.async.stop(shutdown: !!options[:shutdown], timeout: Shoryuken::Later.options[:timeout])
25
+ manager.wait(:shutdown)
26
+ end
27
+ end
28
+
29
+ def run
30
+ watchdog('Later::Launcher#run') do
31
+ manager.async.start
32
+ end
33
+ end
34
+
35
+ def actor_died(actor, reason)
36
+ return if @done
37
+ logger.warn 'Shoryuken::Later died due to the following error, cannot recover, process exiting'
38
+ exit 1
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,138 @@
1
+ # Most of this has been "borrowed" from Shoryuken, but then repurposed for periodic polling.
2
+
3
+ # @see Shoryuken::Manager
4
+ require 'set'
5
+ require 'shoryuken/later/poller'
6
+
7
+ module Shoryuken
8
+ module Later
9
+ class Manager
10
+ include Celluloid
11
+ include Shoryuken::Util
12
+
13
+ def initialize
14
+ @tables = Shoryuken::Later.tables.dup.uniq
15
+
16
+ @done = false
17
+
18
+ @idle = Set.new([])
19
+ @busy = Set.new([])
20
+ @timers = {}
21
+
22
+ @tables.each{|table| Poller.supervise_as :"poller-#{table}", current_actor, table }
23
+ end
24
+
25
+ def start
26
+ logger.info 'Starting'
27
+
28
+ # Start a poller for every table being polled.
29
+ @tables.each do |table|
30
+ dispatch table
31
+
32
+ # Save the timer so it can be cancelled at shutdown.
33
+ @timers[table] = every(Shoryuken::Later.poll_delay) { dispatch table }
34
+ end
35
+ end
36
+
37
+ def stop(options = {})
38
+ watchdog('Later::Manager#stop died') do
39
+ @done = true
40
+
41
+ @timers.each_value{|timer| timer.cancel if timer }
42
+ @timers.clear
43
+
44
+ logger.info { "Shutting down #{@idle.size} idle poller(s)" }
45
+
46
+ @idle.each do |name|
47
+ poller = Actor[name] and poller.alive? and poller.terminate
48
+ end
49
+ @idle.clear
50
+
51
+ if @busy.empty?
52
+ return after(0) { signal(:shutdown) }
53
+ end
54
+
55
+ if options[:shutdown]
56
+ hard_shutdown_in(options[:timeout])
57
+ else
58
+ soft_shutdown(options[:timeout])
59
+ end
60
+ end
61
+ end
62
+
63
+ def poller_done(table, poller)
64
+ watchdog('Later::Manager#poller_done died') do
65
+ logger.debug { "Poller done for '#{table}'" }
66
+
67
+ name = :"poller-#{table}"
68
+ @busy.delete name
69
+
70
+ if stopped?
71
+ poller.terminate if poller.alive?
72
+ else
73
+ @idle << name
74
+ end
75
+ end
76
+ end
77
+
78
+ def poller_ready(table, poller)
79
+ watchdog('Later::Manager#poller_ready died') do
80
+ logger.debug { "Poller for '#{table}' ready" }
81
+
82
+ name = :"poller-#{table}"
83
+ @busy.delete name
84
+ @idle << name
85
+ end
86
+ end
87
+
88
+ def stopped?
89
+ @done
90
+ end
91
+
92
+ private
93
+
94
+ def dispatch(table)
95
+ name = :"poller-#{table}"
96
+
97
+ # Only start polling if the poller is idle.
98
+ if ! stopped? && @idle.include?(name)
99
+ @idle.delete(name)
100
+ @busy << name
101
+
102
+ Actor[name].async.poll
103
+ end
104
+ end
105
+
106
+ def soft_shutdown(delay)
107
+ logger.info { "Waiting for #{@busy.size} busy pollers" }
108
+
109
+ if @busy.size > 0
110
+ after(delay) { soft_shutdown(delay) }
111
+ else
112
+ after(0) { signal(:shutdown) }
113
+ end
114
+ end
115
+
116
+ def hard_shutdown_in(delay)
117
+ logger.info { "Waiting for #{@busy.size} busy pollers" }
118
+ logger.info { "Pausing up to #{delay} seconds to allow pollers to finish..." }
119
+
120
+ after(delay) do
121
+ watchdog("Later::Manager#hard_shutdown_in died") do
122
+ if @busy.size > 0
123
+ logger.info { "Hard shutting down #{@busy.size} busy pollers" }
124
+
125
+ @busy.each do |busy|
126
+ if poller = Actor[busy]
127
+ t = poller.bare_object.actual_work_thread
128
+ t.raise Shutdown if poller.alive?
129
+ end
130
+ end
131
+ end
132
+ after(0) { signal(:shutdown) }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,91 @@
1
+ require 'json'
2
+
3
+ module Shoryuken
4
+ module Later
5
+ class Poller
6
+ include Celluloid
7
+ include Shoryuken::Util
8
+
9
+ attr_reader :table_name
10
+
11
+ def initialize(manager, table_name)
12
+ @manager = manager
13
+ @table_name = table_name
14
+
15
+ @manager.async.poller_ready(@table_name, self)
16
+ end
17
+
18
+ def poll
19
+ watchdog('Later::Poller#poll died') do
20
+ started_at = Time.now
21
+
22
+ logger.debug { "Polling for scheduled messages in '#{@table_name}'" }
23
+
24
+ begin
25
+ while item = next_item
26
+ id = item.attributes['id']
27
+ logger.info "Found message #{id} from '#{@table_name}'"
28
+ defer do
29
+ if sent_msg = process_item(item)
30
+ logger.debug { "Enqueued message #{id} from '#{@table_name}' as #{sent_msg.id}" }
31
+ else
32
+ logger.debug { "Skipping already queued message #{id} from '#{@table_name}'" }
33
+ end
34
+ end
35
+ end
36
+
37
+ logger.debug { "Poller for '#{@table_name}' completed in #{elapsed(started_at)} ms" }
38
+ rescue => ex
39
+ logger.error "Error fetching message: #{ex}"
40
+ logger.error ex.backtrace.first
41
+ end
42
+
43
+ @manager.async.poller_done(@table_name, self)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def table
50
+ Shoryuken::Later::Client.tables(@table_name)
51
+ end
52
+
53
+ # Fetches the next available item from the schedule table.
54
+ def next_item
55
+ table.items.where(:perform_at).less_than((Time.now + Shoryuken::Later::MAX_QUEUE_DELAY).to_i).first
56
+ end
57
+
58
+ # Processes an item and enqueues it (unless another actor has already enqueued it).
59
+ def process_item(item)
60
+ time, worker_class, args, id = item.attributes.values_at('perform_at','shoryuken_class','shoryuken_args','id')
61
+
62
+ worker_class = worker_class.constantize
63
+ args = JSON.parse(args)
64
+ time = Time.at(time)
65
+ queue_name = item.attributes['shoryuken_queue']
66
+
67
+ # Conditionally delete an item prior to enqueuing it, ensuring only one actor may enqueue it.
68
+ begin item.delete(:if => {id: id})
69
+ rescue AWS::DynamoDB::Errors::ConditionalCheckFailedException => e
70
+ # Item was already deleted, so it does not need to be queued.
71
+ return
72
+ end
73
+
74
+ # Now the item is safe to be enqueued, since the conditional delete succeeded.
75
+ body, options = args.values_at('body','options')
76
+ if queue_name.nil?
77
+ worker_class.perform_in(time, body, options)
78
+
79
+ # For compatibility with Shoryuken's ActiveJob adapter, support an explicit queue name.
80
+ else
81
+ delay = (time - Time.now).to_i
82
+ options[:delay_seconds] = delay if delay > 0
83
+ options[:message_attributes] ||= {}
84
+ options[:message_attributes]['shoryuken_class'] = { string_value: worker_class.to_s, data_type: 'String' }
85
+ Shoryuken::Client.send_message(queue_name, body, options)
86
+ end
87
+ end
88
+
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ module Shoryuken
2
+ module Later
3
+ VERSION = '0.0.1'
4
+ end
5
+ end
@@ -0,0 +1,31 @@
1
+ require 'time'
2
+ require 'json'
3
+
4
+ module Shoryuken
5
+ module Later
6
+ module Worker
7
+ module ClassMethods
8
+
9
+ def perform_later(time, body, options = {})
10
+ time = Time.now + time.to_i if Numeric===time
11
+ time = time.to_time if time.respond_to?(:to_time)
12
+ raise ArgumentError, 'expected Numeric, Time but got '+time.class.name unless Time===time
13
+
14
+ # Times that are less than 15 minutes in the future can be queued immediately.
15
+ if time < Time.now + Shoryuken::Later::MAX_QUEUE_DELAY
16
+ perform_in(time, body, options)
17
+
18
+ # Otherwise, the message is inserted into a DynamoDB table with the same name as the queue.
19
+ else
20
+ table = get_shoryuken_options['schedule_table'] || Shoryuken::Later.default_table
21
+ args = JSON.dump(body: body, options: options)
22
+ Shoryuken::Later::Client.put_item(table, perform_at: time.to_i, shoryuken_args: args,
23
+ shoryuken_class: self.to_s)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ Worker::ClassMethods.send :include, Later::Worker::ClassMethods
31
+ end
@@ -0,0 +1,52 @@
1
+ require 'shoryuken'
2
+ require 'shoryuken/later/version'
3
+ require 'shoryuken/later/client'
4
+ require 'shoryuken/later/worker'
5
+
6
+ module Shoryuken
7
+ module Later
8
+ MAX_QUEUE_DELAY = 15 * 60
9
+
10
+ DEFAULT_POLL_DELAY = 5 * 60
11
+
12
+ DEFAULTS = {
13
+ aws: {},
14
+ later: {
15
+ tables: [],
16
+ delay: DEFAULT_POLL_DELAY,
17
+ },
18
+ timeout: 8
19
+ }
20
+
21
+ @@tables = []
22
+ @@default_table = 'shoryuken_later'
23
+
24
+ class << self
25
+ def options
26
+ @options ||= DEFAULTS.dup
27
+ end
28
+
29
+ def poll_delay
30
+ options[:later][:delay] || DEFAULT_POLL_DELAY
31
+ end
32
+
33
+ def default_table
34
+ @@default_table
35
+ end
36
+
37
+ def default_table=(table)
38
+ @@default_table = table
39
+ end
40
+
41
+ def tables
42
+ @@tables
43
+ end
44
+
45
+ def logger
46
+ Shoryuken::Logging.logger
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ require 'shoryuken/later/active_job_adapter' if defined?(::ActiveJob)
@@ -0,0 +1 @@
1
+ require 'shoryuken/later'
@@ -0,0 +1,32 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "shoryuken/later/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "shoryuken-later"
8
+ spec.version = Shoryuken::Later::VERSION
9
+ spec.authors = ["Joe Khoobyar"]
10
+ spec.email = ["joe@khoobyar.name"]
11
+ spec.homepage = "http://github.com/joekhoobyar/shoryuken-later"
12
+ spec.summary = 'A scheduling plugin (using Dynamo DB) for Shoryuken'
13
+ spec.description = %Q{
14
+ This gem provides a scheduling plugin (using Dynamo DB) for Shoryuken, as well as an ActiveJob adapter
15
+ }
16
+
17
+ spec.license = "LGPL-3.0"
18
+ spec.files = `git ls-files -z`.split("\x0")
19
+ spec.executables = %w[shoryuken-later]
20
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.required_ruby_version = '>= 1.9.3'
24
+
25
+ spec.add_development_dependency "bundler", '>= 1.3.5'
26
+ spec.add_development_dependency "rake", '~> 10.0'
27
+ spec.add_development_dependency "rspec", '~> 3.0', '< 3.1'
28
+
29
+ spec.add_dependency "aws-sdk-v1"
30
+ spec.add_dependency "celluloid", "~> 0.15.2"
31
+ spec.add_dependency "shoryuken", "~> 0.0.4"
32
+ end
@@ -0,0 +1,42 @@
1
+ require 'spec_helper'
2
+
3
+ describe Shoryuken::Later::Client do
4
+ let(:ddb) { double 'DynamoDB' }
5
+ let(:table_collection) { double 'Table Collection' }
6
+ let(:ddb_table) { double 'DynamoDb Table' }
7
+ let(:ddb_items) { double 'Table Items' }
8
+ let(:table) { 'shoryuken_later' }
9
+
10
+ before do
11
+ allow(described_class).to receive(:ddb).and_return(ddb)
12
+ allow(ddb).to receive(:tables).and_return(table_collection)
13
+ allow(table_collection).to receive(:[]).and_return(ddb_table)
14
+ allow(ddb_table).to receive(:items).and_return(ddb_items)
15
+ allow(ddb_table).to receive(:hash_key=)
16
+ end
17
+
18
+ describe '.tables' do
19
+ it 'memoizes tables and sets the hash_key' do
20
+ expect(table_collection).to receive(:[]).once.with(table).and_return(ddb_table)
21
+ expect(ddb_table).to receive(:hash_key=).once.with([:id, :string])
22
+
23
+ expect(described_class.tables(table)).to eq(ddb_table)
24
+ expect(described_class.tables(table)).to eq(ddb_table)
25
+ end
26
+ end
27
+
28
+ describe '.put_item' do
29
+ it 'creates an item with a supplied ID' do
30
+ expect(ddb_items).to receive(:create).with({'id' => 'fubar'}, {unless_exists: 'id'})
31
+
32
+ described_class.put_item(table,'id' => 'fubar')
33
+ end
34
+
35
+ it 'creates an item with a auto-generated ID' do
36
+ expect(SecureRandom).to receive(:uuid).once.and_return('fubar')
37
+ expect(ddb_items).to receive(:create).with({'id' => 'fubar', 'perform_at' => 1234}, {unless_exists: 'id'})
38
+
39
+ described_class.put_item(table,'perform_at' => 1234)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+ require 'shoryuken/later/poller'
3
+ require 'shoryuken/later/manager'
4
+
5
+ describe Shoryuken::Later::Poller do
6
+ let(:manager) { double Shoryuken::Later::Manager, poller_ready: nil, poller_done: nil }
7
+ let(:ddb_table) { double 'DynamoDb Table' }
8
+ let(:ddb_items) { double 'Table Items' }
9
+ let(:table) { 'shoryuken_later' }
10
+
11
+ let(:body) { {'foo' => 'bar'} }
12
+ let(:json) { JSON.dump(body: body, options: {}) }
13
+
14
+ let(:ddb_item) do
15
+ double AWS::DynamoDB::Item, delete: nil,
16
+ attributes: {'id' => 'fubar', 'perform_at' => Time.now + 60, 'shoryuken_args' => json, 'shoryuken_class' => 'TestWorker'}
17
+ end
18
+
19
+ before do
20
+ allow(manager).to receive(:async).and_return(manager)
21
+ allow(Shoryuken::Later::Client).to receive(:tables).with(table).and_return(ddb_table)
22
+ end
23
+
24
+ subject do
25
+ described_class.new(manager, table)
26
+ end
27
+
28
+ describe '#initialize' do
29
+ it 'informs the manager that the poller is ready' do
30
+ expect(manager).to receive(:poller_ready).once
31
+
32
+ subject.inspect
33
+ subject.inspect
34
+ end
35
+ end
36
+
37
+ describe '#poll' do
38
+ it 'pulls items from #next_item, and processes with #process_item' do
39
+ items = [ddb_item]
40
+ expect_any_instance_of(described_class).to receive(:next_item).twice { items.pop }
41
+ expect_any_instance_of(described_class).to receive(:process_item).once.with(ddb_item)
42
+ expect(manager).to receive(:poller_done).once
43
+
44
+ subject.poll
45
+ end
46
+
47
+ it 'informs the manager after polling is done' do
48
+ items = []
49
+ expect_any_instance_of(described_class).to receive(:next_item).once { items.pop }
50
+ expect_any_instance_of(described_class).not_to receive(:process_item)
51
+ expect(manager).to receive(:poller_done).once
52
+
53
+ subject.poll
54
+ end
55
+ end
56
+
57
+ describe '#process_item' do
58
+ it 'enqueues a message if the item could be deleted' do
59
+ expect(TestWorker).to receive(:perform_in).once do |time,body,options|
60
+ expect(time ).to be > Time.now
61
+ expect(body ).to eq(body)
62
+ expect(options).to be_empty
63
+ end
64
+
65
+ subject.send(:process_item, ddb_item)
66
+ end
67
+
68
+ it 'does not enqueue a message if the item could not be deleted' do
69
+ expect(TestWorker).not_to receive(:perform_in)
70
+ allow(ddb_item).to receive(:delete) { raise AWS::DynamoDB::Errors::ConditionalCheckFailedException }
71
+
72
+ subject.send(:process_item, ddb_item)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,33 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Shoryuken::Worker' do
4
+ let(:sqs_queue) { double 'SQS Queue' }
5
+ let(:queue) { 'shoryuken_later' }
6
+ let(:table) { 'shoryuken_later' }
7
+ let(:msg_attrs) { { 'shoryuken_class' => { string_value: TestWorker.to_s, data_type: 'String' } } }
8
+
9
+ before do
10
+ allow(Shoryuken::Client).to receive(:queues).with(queue).and_return(sqs_queue)
11
+ end
12
+
13
+ describe '.perform_later' do
14
+ it 'delays a message for up to 15 minutes in the future' do
15
+ expect(sqs_queue).to receive(:send_message).with('message', { message_attributes: msg_attrs, delay_seconds: 15 * 60 })
16
+
17
+ TestWorker.perform_later(15 * 60, 'message')
18
+ end
19
+
20
+ it 'schedules a message for over 15 minutes in the future' do
21
+ json = JSON.dump(body: 'message', options: {})
22
+ future = Time.now + 15 * 60 + 1
23
+
24
+ expect(Shoryuken::Later::Client).to receive(:put_item) do |table,attrs|
25
+ expect(attrs[:perform_at]).to be >= future.to_i
26
+ expect(attrs[:shoryuken_args]).to eq(json)
27
+ expect(attrs[:shoryuken_class]).to eq('TestWorker')
28
+ end
29
+
30
+ TestWorker.perform_later(15 * 60 + 1, 'message')
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,74 @@
1
+ require 'bundler/setup'
2
+ Bundler.setup
3
+
4
+ require 'celluloid'
5
+ require 'shoryuken-later'
6
+ require 'json'
7
+
8
+ options_file = File.join(File.expand_path('../..', __FILE__), 'shoryuken.yml')
9
+
10
+ $options = {}
11
+
12
+ if File.exists? options_file
13
+ $options = YAML.load(File.read(options_file)).deep_symbolize_keys
14
+
15
+ AWS.config $options[:aws]
16
+ end
17
+
18
+ Shoryuken.logger.level = Logger::UNKNOWN
19
+ Celluloid.logger.level = Logger::UNKNOWN
20
+
21
+ # For Ruby 1.9
22
+ module Kernel
23
+ def Hash(arg)
24
+ case arg
25
+ when NilClass
26
+ {}
27
+ when Hash
28
+ arg
29
+ when Array
30
+ Hash[*arg]
31
+ else
32
+ raise TypeError
33
+ end
34
+ end unless method_defined? :Hash
35
+ end
36
+
37
+ # For Ruby 1.9
38
+ class Hash
39
+ def to_h
40
+ self
41
+ end unless method_defined? :to_h
42
+ end
43
+
44
+ class TestWorker
45
+ include Shoryuken::Worker
46
+
47
+ shoryuken_options queue: 'shoryuken_later', schedule_table: 'shoryuken_later'
48
+
49
+ def perform(sqs_msg, body); end
50
+ end
51
+
52
+ RSpec.configure do |config|
53
+ config.before do
54
+ Shoryuken::Later::Client.class_variable_set :@@tables, {}
55
+
56
+ Shoryuken::Later.options.clear
57
+ Shoryuken::Later.options.merge!($options)
58
+
59
+ Shoryuken::Later.tables.replace(['shoryuken_later'])
60
+
61
+ Shoryuken::Later.options[:later][:delay] = 60
62
+ Shoryuken::Later.options[:later][:tables] = ['shoryuken_later']
63
+ Shoryuken::Later.options[:timeout] = 1
64
+
65
+ Shoryuken::Later.options[:aws] = {}
66
+
67
+ TestWorker.get_shoryuken_options.clear
68
+ TestWorker.get_shoryuken_options['queue'] = 'shoryuken_later'
69
+ TestWorker.get_shoryuken_options['schedule_table'] = 'shoryuken_later'
70
+
71
+ Shoryuken.workers.clear
72
+ Shoryuken.register_worker('shoryuken_later', TestWorker)
73
+ end
74
+ end