stormtroopers 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
- # Stormtroopers [![Build Status](https://secure.travis-ci.org/socialreferral/stormtroopers.png)](http://travis-ci.org/socialreferral/stormtroopers)
1
+ # Stormtroopers
2
2
 
3
- Stormtroopers is a jruby execution environment for delayed jobs, Work In Progress!
3
+ Stormtroopers is a JRuby execution environment for background tasks using threads. It can use different backends, currently it comes with a DelayedJob (light) backend only. **NOTE: While the basic functionality works and is under test, Stormtroopers is still a Work In Progress!**
4
+
5
+ ## Prerequisites
6
+
7
+ JRuby 1.7.0+ in 1.9 mode. Your code and the libraries you are using must be threadsafe. If you're running with Rails in development mode be sure that you do not run more than 1 thread at a time!
4
8
 
5
9
  ## Installation
6
10
 
@@ -18,7 +22,37 @@ Or install it yourself as:
18
22
 
19
23
  ## Usage
20
24
 
21
- configure using a config file 'stormtroopers.yml' and run with `bundle exec stormtroopers`
25
+ Stormtroopers are organized in Armies, each Army has a Factory that produces Troopers. Each Trooper runs a job inside it's own Thread (which is disposed of after job completion or failure).
26
+
27
+ Configure Stormtroopers using a config file 'config/stormtroopers.yml' and execute it using 'bundle exec stormtroopers' from the root directory of your application, here's an example that specifies workers for different DelayedJob queues:
28
+
29
+ ```yaml
30
+ armies:
31
+ - factory:
32
+ type: :delayed_job
33
+ queues:
34
+ - emails
35
+ - posts
36
+ max_threads: 10
37
+ name: "Comms"
38
+ - factory:
39
+ type: :delayed_job
40
+ queues:
41
+ - calculation
42
+ - graphs
43
+ max_threads: 5
44
+ name: "Math"
45
+ ```
46
+
47
+ The above configuration specifies two armies, each with a DelayedJob factory to produce its Troopers.
48
+
49
+ Each Army can be given a set of options:
50
+
51
+ - factory: options that are passed on to the Army's Trooper producing Factory
52
+ - max_threads: the maximum number of Troopers for this Army that may be concurrently running a job at any time
53
+ - name: the Army's name, this is used when logging
54
+
55
+ You set the Army's Factory with the factory type or class option. When using type this is translated into a builtin class, when using class you can specify the name of whatever class you like (it needs to implement the appropriate interface to be able to work of course, have a look through the source code and specs to figure out what you need to do). Besides specifying the type or class, you can also specify the Factory's name (used for logging purposes), if you don't give the Factory a name then the Army's name is propagated to the Factory. The factory options also include specific settings for the chosen backend, for DelayedJob you can specify the queues that jobs may be picked up from. If you don't specify queues then the factory will pick up jobs from all queues.
22
56
 
23
57
  ## Contributing
24
58
 
@@ -4,33 +4,32 @@ module Stormtroopers
4
4
 
5
5
  def initialize(config)
6
6
  @name = config[:name] || factory_class(config).name
7
- @factory = factory_class(config).new(config[:factory])
7
+ @factory = factory_class(config).new({name: config[:name]}.merge(config[:factory]))
8
8
  @max_threads = config[:max_threads] || 1
9
9
  @threads = []
10
10
  end
11
11
 
12
12
  def factory_class(config)
13
- @factory_class ||= begin
14
- raise ArgumentError, "Factory class or type must be defined" if config[:factory][:class].blank? && config[:factory][:type].blank?
15
- class_name ||= config[:factory].delete(:class)
16
- class_name ||= "stormtroopers/#{config[:factory].delete(:type)}_factory".camelize
17
- class_name.constantize
18
- end
13
+ @factory_class ||= self.class.factory_class(config)
19
14
  end
20
15
 
21
16
  def manage
22
17
  cleanup
23
18
  if threads.count < max_threads
24
19
  if trooper = factory.produce
25
- threads << Thread.new do
26
- begin
27
- trooper.run
28
- ensure
29
- if defined?(::Mongoid)
30
- ::Mongoid::IdentityMap.clear
31
- ::Mongoid.session(:default).disconnect
32
- end
33
- end
20
+ run_trooper(trooper)
21
+ end
22
+ end
23
+ end
24
+
25
+ def run_trooper(trooper)
26
+ threads << Thread.new do
27
+ begin
28
+ trooper.run
29
+ ensure
30
+ if defined?(::Mongoid)
31
+ ::Mongoid::IdentityMap.clear
32
+ ::Mongoid.session(:default).disconnect
34
33
  end
35
34
  end
36
35
  end
@@ -41,14 +40,21 @@ module Stormtroopers
41
40
  threads.each(&:join)
42
41
  end
43
42
 
43
+ def cleanup
44
+ threads.reject!{ |thread| !thread.alive? }
45
+ end
46
+
44
47
  def logger
45
48
  Stormtroopers::Manager.logger
46
49
  end
47
50
 
48
- private
49
-
50
- def cleanup
51
- threads.reject!{ |thread| !thread.alive? }
51
+ class << self
52
+ def factory_class(config)
53
+ raise ArgumentError, "Factory class or type must be defined" if config[:factory][:class].blank? && config[:factory][:type].blank?
54
+ class_name ||= config[:factory].delete(:class)
55
+ class_name ||= "stormtroopers/#{config[:factory].delete(:type)}_factory".camelize
56
+ class_name.constantize
57
+ end
52
58
  end
53
59
  end
54
60
  end
@@ -16,20 +16,18 @@ module Stormtroopers
16
16
  end
17
17
 
18
18
  def exception(exception)
19
- raise
20
- # Empty hook for overriding
19
+ # Hook for to override handling exceptions
20
+ raise exception
21
21
  end
22
22
 
23
23
  def run
24
- before_execution
24
+ before_run
25
25
  task.call
26
- after_execution
26
+ after_run
27
27
  rescue => e
28
28
  exception(e)
29
29
  end
30
30
 
31
- private
32
-
33
31
  def logger
34
32
  Manager.logger
35
33
  end
@@ -15,18 +15,19 @@ module Stormtroopers
15
15
  reschedule
16
16
  end
17
17
 
18
- private
19
-
20
18
  def reschedule
21
19
  if (job.attempts += 1) < max_attempts(job)
22
- time ||= job.reschedule_at
23
- job.run_at = time
20
+ job.run_at = job.reschedule_at
24
21
  job.unlock
25
22
  job.save!
26
23
  else
27
- logger.error "PERMANENTLY removing #{job.name} because of #{job.attempts} consecutive failures.", Logger::INFO
24
+ logger.error("PERMANENTLY removing #{job.name} because of #{job.attempts} consecutive failures.")
28
25
  job.hook(:failure)
29
26
  end
30
27
  end
28
+
29
+ def max_attempts(job)
30
+ job.max_attempts || Delayed::Worker.max_attempts
31
+ end
31
32
  end
32
- end
33
+ end
@@ -1,3 +1,3 @@
1
1
  module Stormtroopers
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -0,0 +1,122 @@
1
+ require "stormtroopers"
2
+
3
+ describe Stormtroopers::Army do
4
+ let(:army) do
5
+ Stormtroopers::Army.new(name: "Dad's Army", max_threads: 2, factory: {name: "Dad's Factory", type: :dummy})
6
+ end
7
+
8
+ describe "#initialize" do
9
+ it "extracts the name from the options" do
10
+ army.name.should eq("Dad's Army")
11
+ end
12
+
13
+ it "extracts the max_threads from the options" do
14
+ army.max_threads.should eq(2)
15
+ end
16
+
17
+ it "instatiates a factory using #factory_class" do
18
+ factory_class = stub
19
+ Stormtroopers::Army.any_instance.should_receive(:factory_class).and_return(factory_class)
20
+ factory_instance = stub
21
+ factory_class.should_receive(:new).with(name: "Dad's Factory", type: :dummy).and_return(factory_instance)
22
+ army.factory.should equal(factory_instance)
23
+ end
24
+
25
+ it "when there is no factory name, the army name is used as the factory name" do
26
+ factory_class = stub
27
+ Stormtroopers::Army.any_instance.should_receive(:factory_class).and_return(factory_class)
28
+ factory_instance = stub
29
+ factory_class.should_receive(:new).with(name: "Dad's Army", type: :dummy).and_return(factory_instance)
30
+ army = Stormtroopers::Army.new(name: "Dad's Army", max_threads: 2, factory: {type: :dummy})
31
+ army.factory.should equal(factory_instance)
32
+ end
33
+ end
34
+
35
+ describe "#cleanup" do
36
+ it "removes threads that are not alive" do
37
+ live_thread_stub = stub(:alive? => true)
38
+ dead_thread_stub = stub(:alive? => false)
39
+ army.stub(:threads).and_return([live_thread_stub, dead_thread_stub])
40
+ army.cleanup
41
+ army.threads.should eq([live_thread_stub])
42
+ end
43
+ end
44
+
45
+ describe "#manage" do
46
+ it "cleans up" do
47
+ army.should_receive(:cleanup)
48
+ army.manage
49
+ end
50
+
51
+ it "produces a trooper and runs it if not at max_threads" do
52
+ trooper = stub
53
+ army.factory.should_receive(:produce).and_return(trooper)
54
+ army.should_receive(:run_trooper).with(trooper)
55
+ army.manage
56
+ end
57
+
58
+ it "does not produce a trooper if at max_threads" do
59
+ thread_stub = stub(:alive? => true)
60
+ army.stub(:threads).and_return([thread_stub, thread_stub])
61
+ army.factory.should_not_receive(:produce)
62
+ army.manage
63
+ end
64
+ end
65
+
66
+ describe "#run_trooper" do
67
+ it "creates a new thread and runs the trooper in it" do
68
+ trooper = mock
69
+ trooper.should_receive(:run)
70
+ Thread.should_receive(:new).and_yield
71
+ army.run_trooper(trooper)
72
+ end
73
+
74
+ it "cleans up the Mongoid environment if Mongoid is defined" do
75
+ stub_const("Mongoid", Class.new)
76
+ stub_const("Mongoid::IdentityMap", Class.new)
77
+ Mongoid::IdentityMap.should_receive(:clear)
78
+ mongoid_session = stub
79
+ Mongoid.should_receive(:session).with(:default).and_return(mongoid_session)
80
+ mongoid_session.should_receive(:disconnect)
81
+ trooper = mock
82
+ trooper.should_receive(:run)
83
+ Thread.should_receive(:new).and_yield
84
+ army.run_trooper(trooper)
85
+ end
86
+ end
87
+
88
+ describe "#finish" do
89
+ it "calls join on all threads" do
90
+ thread1 = stub
91
+ thread1.should_receive(:join)
92
+ thread2 = stub
93
+ thread2.should_receive(:join)
94
+ army.stub(:threads).and_return([thread1, thread2])
95
+ army.finish
96
+ end
97
+ end
98
+
99
+ describe "#logger" do
100
+ it "takes the logger from Stormtroopers::Manager" do
101
+ logger = stub
102
+ Stormtroopers::Manager.stub(:logger).and_return(logger)
103
+ army.logger.should equal(logger)
104
+ end
105
+ end
106
+
107
+ describe ".factory_class" do
108
+ it "returns the class specified in the options" do
109
+ stub_const("MyFactory", Class.new)
110
+ Stormtroopers::Army.factory_class(factory: {class: "MyFactory"}).should equal(MyFactory)
111
+ end
112
+
113
+ it "returns a builtin factory class when type is specified in the options" do
114
+ Stormtroopers::Army.factory_class(factory: {type: :dummy}).should equal(Stormtroopers::DummyFactory)
115
+ Stormtroopers::Army.factory_class(factory: {type: :delayed_job}).should equal(Stormtroopers::DelayedJobFactory)
116
+ end
117
+
118
+ it "raises an ArgumentError when no type or class is specified" do
119
+ expect { Stormtroopers::Army.factory_class(factory: {}) }.to raise_error(ArgumentError)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,42 @@
1
+ require "stormtroopers/factory/delayed_job"
2
+
3
+ describe Stormtroopers::DelayedJobFactory do
4
+ let(:factory) { Stormtroopers::DelayedJobFactory.new }
5
+ let(:worker) { stub }
6
+
7
+ before(:each) do
8
+ stub_const("Delayed::Worker", Class.new)
9
+ Delayed::Worker.stub(:new).and_return(worker)
10
+ worker.stub(:name=).and_return(nil)
11
+ worker.stub(:name).and_return(nil)
12
+ stub_const("Delayed::Job", Class.new)
13
+ Delayed::Job.stub(:reserve)
14
+ end
15
+
16
+ describe "#produce" do
17
+ it "instantiates a new worker" do
18
+ Delayed::Worker.should_receive(:new).and_return(worker)
19
+ factory.produce
20
+ end
21
+
22
+ it "assigns the worker a name with random value" do
23
+ Time.stub_chain(:now, :utc, :to_f).and_return("1234")
24
+ factory.should_receive(:rand).and_return(500)
25
+ worker.should_receive(:name=).with("rand 1234 500")
26
+ factory.produce
27
+ end
28
+
29
+ it "reserves a job and instantiates a trooper" do
30
+ job = stub.as_null_object
31
+ trooper = stub
32
+ Delayed::Job.should_receive(:reserve).and_return(job)
33
+ Stormtroopers::DelayedJobTrooper.should_receive(:new).and_return(trooper)
34
+ factory.produce.should equal(trooper)
35
+ end
36
+
37
+ it "does not instantiate a trooper if it doesn't find a job to reserve" do
38
+ Delayed::Job.should_receive(:reserve).and_return(nil)
39
+ factory.produce.should be_nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ require "stormtroopers/factory"
2
+
3
+ describe Stormtroopers::Factory do
4
+ describe "#options" do
5
+ it "exposes the options passed in during #initialize" do
6
+ options = {key1: "value1", key2: "value2"}
7
+ factory = Stormtroopers::Factory.new(options)
8
+ factory.options.should equal(options)
9
+ end
10
+ end
11
+
12
+ describe "#produce" do
13
+ it "is not implemented" do
14
+ factory = Stormtroopers::Factory.new
15
+ expect { factory.produce }.to raise_error(NotImplementedError)
16
+ end
17
+ end
18
+
19
+ describe "#logger" do
20
+ it "uses the Stormtroopers::Manager#logger" do
21
+ stub_const("Stormtroopers::Manager", Class.new)
22
+ logger = stub
23
+ Stormtroopers::Manager.stub(:logger).and_return(logger)
24
+ factory = Stormtroopers::Factory.new
25
+ factory.logger.should equal(logger)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,56 @@
1
+ require "stormtroopers/trooper/delayed_job"
2
+
3
+ describe Stormtroopers::DelayedJobTrooper do
4
+ let(:job) { stub.as_null_object }
5
+ let(:trooper) { Stormtroopers::DelayedJobTrooper.new(job) }
6
+
7
+ describe "#initialize" do
8
+ it "accepts a job and exposes it as #job" do
9
+ job = stub.as_null_object
10
+ trooper = Stormtroopers::DelayedJobTrooper.new(job)
11
+ trooper.job.should equal(job)
12
+ end
13
+ end
14
+
15
+ describe "#run" do
16
+ context "a succesful job" do
17
+ it "calls invoke job.invoke_job and then job.destroy" do
18
+ job.should_receive(:invoke_job)
19
+ job.should_receive(:destroy)
20
+ trooper.run
21
+ end
22
+ end
23
+
24
+ context "a failing job" do
25
+ it "sets job.last_error and reschedules the job" do
26
+ job.should_receive(:invoke_job) { raise "Oops!" }
27
+ job.should_not_receive(:destroy)
28
+ job.should_receive(:last_error=).with(match("Oops!"))
29
+ trooper.should_receive(:reschedule)
30
+ trooper.run
31
+ end
32
+ end
33
+ end
34
+
35
+ describe "#reschedule" do
36
+ context "job has not reached max attempts" do
37
+ it "should reschedule and unlock" do
38
+ job.stub(:attempts).and_return(1)
39
+ job.stub(:max_attempts).and_return(3)
40
+ job.should_receive(:run_at=)
41
+ job.should_receive(:unlock)
42
+ job.should_receive(:save!)
43
+ trooper.reschedule
44
+ end
45
+ end
46
+
47
+ context "job has reached max attempts" do
48
+ it "should fail the job" do
49
+ job.stub(:attempts).and_return(3)
50
+ job.stub(:max_attempts).and_return(3)
51
+ job.should_receive(:hook).with(:failure)
52
+ trooper.reschedule
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,56 @@
1
+ require "stormtroopers/trooper"
2
+
3
+ describe Stormtroopers::Trooper do
4
+ before(:each) do
5
+ stub_const("Stormtroopers::Manager", Class.new)
6
+ logger = stub
7
+ Stormtroopers::Manager.stub(:logger).and_return(logger)
8
+ end
9
+
10
+ describe "#initialize" do
11
+ it "it accepts parameters and exposes them through #parameters" do
12
+ parameters = {key1: "value1", key2: "value2"}
13
+ trooper = Stormtroopers::Trooper.new(parameters)
14
+ trooper.parameters.should equal(parameters)
15
+ end
16
+
17
+ it "accepts a task block and exposes it through #task" do
18
+ task = lambda { puts "This is a task" }
19
+ trooper = Stormtroopers::Trooper.new({}, &task)
20
+ trooper.task.should equal(task)
21
+ end
22
+ end
23
+
24
+ describe "#run" do
25
+ let(:task) { lambda { puts "This is a task" } }
26
+ let(:trooper) { Stormtroopers::Trooper.new({}, &task) }
27
+
28
+ it "calls the before_run hook" do
29
+ trooper.should_receive(:before_run)
30
+ trooper.run
31
+ end
32
+
33
+ it "calls call on the task" do
34
+ task.should_receive(:call)
35
+ trooper.run
36
+ end
37
+
38
+ it "calls the after_run hook" do
39
+ trooper.should_receive(:after_run)
40
+ trooper.run
41
+ end
42
+
43
+ it "when the task raises an exception the exception hook is called" do
44
+ task.stub(:call) { raise "Oops" }
45
+ trooper.should_receive(:exception)
46
+ trooper.run
47
+ end
48
+ end
49
+
50
+ describe "#logger" do
51
+ it "uses the Stormtroopers::Manager#logger" do
52
+ trooper = Stormtroopers::Trooper.new
53
+ trooper.logger.should equal(Stormtroopers::Manager.logger)
54
+ end
55
+ end
56
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stormtroopers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2012-11-12 00:00:00.000000000 Z
13
+ date: 2012-11-13 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activesupport
@@ -76,7 +76,12 @@ files:
76
76
  - lib/stormtroopers/trooper/delayed_job.rb
77
77
  - lib/stormtroopers/trooper/dummy.rb
78
78
  - lib/stormtroopers/version.rb
79
+ - spec/stormtroopers/army_spec.rb
80
+ - spec/stormtroopers/factory/delayed_job_spec.rb
81
+ - spec/stormtroopers/factory_spec.rb
79
82
  - spec/stormtroopers/manager_spec.rb
83
+ - spec/stormtroopers/trooper/delayed_job_spec.rb
84
+ - spec/stormtroopers/trooper_spec.rb
80
85
  - stormtroopers.gemspec
81
86
  - vagrant-provision
82
87
  homepage: http://github.com/socialreferral/stormtroopers
@@ -106,4 +111,9 @@ signing_key:
106
111
  specification_version: 3
107
112
  summary: Execute delayed jobs in a threaded jruby environment
108
113
  test_files:
114
+ - spec/stormtroopers/army_spec.rb
115
+ - spec/stormtroopers/factory/delayed_job_spec.rb
116
+ - spec/stormtroopers/factory_spec.rb
109
117
  - spec/stormtroopers/manager_spec.rb
118
+ - spec/stormtroopers/trooper/delayed_job_spec.rb
119
+ - spec/stormtroopers/trooper_spec.rb