juggler 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -1,3 +1,3 @@
1
1
  .DS_Store
2
2
  pkg/*
3
- juggler.gemspec
3
+ Gemfile.lock
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in foo.gemspec
4
+ gemspec
data/README.md CHANGED
@@ -2,17 +2,17 @@ Add jobs for asynchronous processing
2
2
 
3
3
  Juggler.throw(:method, params)
4
4
 
5
- Add handlers, with optional concurrency, inside and EM loop
5
+ Add handlers, with optional concurrency, inside an EM loop
6
6
 
7
7
  EM.run {
8
- Juggler.juggle(:method, 10) do |params|
9
- # This code must return an eventmachine deferrable object
8
+ Juggler.juggle(:method, 10) do |deferrable, params|
9
+ # Succeed the deferrable when the job is done
10
10
  end
11
11
  }
12
12
 
13
13
  For example
14
14
 
15
- Juggler.juggle(:download, 10) do |params|
15
+ Juggler.juggle(:download, 10) do |df, params|
16
16
  http = EM::Protocols::HttpClient.request({
17
17
  :host => params[:host],
18
18
  :port => 80,
@@ -20,6 +20,39 @@ For example
20
20
  })
21
21
  http.callback do |response|
22
22
  puts "Got response status #{response[:status]} for #{a}"
23
+ df.success
23
24
  end
24
- http
25
25
  end
26
+
27
+ The job is considered to have failed in the following cases:
28
+
29
+ * If the block raises an error
30
+ * If the block fails the passed deferrable
31
+ * If the job timeout is exceeded. In this case the passed deferrable will be failed by juggler. If you need to clean up any state in this case (for example you might want to cancel a HTTP request) then you should bind to `df.errback`.
32
+
33
+ ## Customising behaviour
34
+
35
+ ### Customising the back-off for failed jobs
36
+
37
+ By default, juggler will backoff jobs which failed exponentially using an exponent of 1.3, up to a maximum delay of 1 day, at which point the job will be buried. It's possible to customise this behaviour:
38
+
39
+ Juggler.backoff_function = lambda { |job_runner, job_stats|
40
+ # job_stats is a hash with string keys, as returned by beanstalkd's
41
+ # stats-job command. Particularly useful stats in this context are:
42
+ #
43
+ # job_stats["age"] - the time since the put command that created this job
44
+ # job_stats["delay"] - the previous amount of time delayed
45
+
46
+ new_delay = ([1, job_stats["delay"] * 2].max).ceil
47
+ if job_stats["age"] > 300
48
+ job_runner.delete
49
+ # Or you could bury the job
50
+ # job_runner.bury
51
+ else
52
+ job_runner.release(new_delay)
53
+ end
54
+ }
55
+
56
+ ## Important points to note
57
+
58
+ * If your deferrable code raises errors, this will not be handled by juggler.
data/Rakefile CHANGED
@@ -1,17 +1,10 @@
1
- require 'rubygems'
2
- require 'rake'
1
+ require "bundler/gem_tasks"
3
2
 
4
- begin
5
- require 'jeweler'
6
- Jeweler::Tasks.new do |gem|
7
- gem.name = "juggler"
8
- gem.summary = %Q{Juggling background jobs with EventMachine and Beanstalkd}
9
- gem.description = %Q{Juggling background jobs with EventMachine and Beanstalkd}
10
- gem.email = "me@mloughran.com"
11
- gem.homepage = "http://github.com/mloughran/juggler"
12
- gem.authors = ["Martyn Loughran"]
13
- # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
14
- end
15
- rescue LoadError
16
- puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec) do |s|
6
+ s.pattern = 'spec/**/*.rb'
17
7
  end
8
+
9
+ task :default => :spec
10
+ task :test => :spec
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.2
1
+ 0.1.0
@@ -5,3 +5,8 @@ get '/slow' do
5
5
  sleep 3
6
6
  return 'Finally done'
7
7
  end
8
+
9
+ get '/fast' do
10
+ sleep 1
11
+ return 'Fast done'
12
+ end
@@ -1,14 +1,13 @@
1
1
  require 'rubygems'
2
2
  $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
3
  require 'juggler'
4
- require 'eventmachine'
5
4
 
6
- Juggler.logger = Logger.new(STDOUT)
5
+ Juggler.logger.level = Logger::DEBUG
7
6
 
8
7
  EM.run {
9
8
  Juggler.juggle(:http, 3) do |path|
10
9
  http = EM::Protocols::HttpClient.request({
11
- :host => "localhost",
10
+ :host => "0.0.0.0",
12
11
  :port => 4567,
13
12
  :request => path
14
13
  })
@@ -22,7 +21,7 @@ EM.run {
22
21
  Juggler.juggle(:timer, 5) do |params|
23
22
  defer = EM::DefaultDeferrable.new
24
23
 
25
- EM::Timer.new(1) do
24
+ EM::Timer.new(10) do
26
25
  defer.set_deferred_status :succeeded, nil
27
26
  # defer.set_deferred_status :failed, nil
28
27
  end
@@ -2,19 +2,25 @@ require 'rubygems'
2
2
  $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
3
  require 'juggler'
4
4
 
5
- Juggler.logger = Logger.new(STDOUT)
6
-
7
5
  # Throw some jobs
8
6
 
9
- class Foo; end
7
+ # EM.run {
8
+ # # 10.times do |i|
9
+ # # path = ['/fast', '/slow'][i % 2]
10
+ # # Juggler.throw(:http, path, :ttr => 40)
11
+ # # end
12
+ # 1.times { Juggler.throw(:timer, {:foo => 'bar'}, :ttr => 5) }
13
+ # }
10
14
 
11
- 2.times do |i|
12
- path = ['/fast', '/slow'][i % 2]
13
- Juggler.throw(:http, path, {
14
- :ttr => 1
15
- })
15
+ Thread.new do
16
+ EM.run
16
17
  end
17
- 10.times { Juggler.throw(:timer, {:foo => 'bar'}) }
18
18
 
19
- # Test that unmarshaling errors are handled
20
- Juggler.throw(:http, Foo.new)
19
+ loop do
20
+ puts "Choose your ttr!"
21
+ time = gets.to_i
22
+ puts "Creating job with ttr #{time}"
23
+ EM.next_tick {
24
+ Juggler.throw(:timer, {:foo => 'bar'}, :ttr => time)
25
+ }
26
+ end
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "juggler"
5
+ s.version = "0.1.0"
6
+ s.authors = ["Martyn Loughran"]
7
+ s.email = ["me@mloughran.com"]
8
+ s.homepage = "https://github.com/mloughran/juggler"
9
+ s.summary = %q{Juggling background jobs with EventMachine and Beanstalkd}
10
+ s.description = %q{Juggling background jobs with EventMachine and Beanstalkd}
11
+
12
+ s.add_runtime_dependency "em-jack", "~> 0.1.0"
13
+
14
+ s.add_development_dependency "em-spec"
15
+ s.add_development_dependency "rake"
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
+ s.require_paths = ["lib"]
21
+ end
@@ -1,17 +1,31 @@
1
- require 'beanstalk-client'
2
-
3
- autoload :Logger, 'logger'
1
+ require 'em-jack'
2
+ require 'eventmachine'
3
+ require 'uri'
4
4
 
5
5
  class Juggler
6
6
  class << self
7
- attr_writer :hosts, :logger
7
+ attr_writer :logger
8
+ attr_writer :shutdown_grace_timeout
9
+ attr_accessor :exception_handler
10
+ attr_accessor :backoff_function
11
+
12
+ def server=(uri)
13
+ @server = URI.parse(uri)
14
+ end
8
15
 
9
- def hosts
10
- @hosts ||= ['localhost:11300']
16
+ def server
17
+ @server ||= URI.parse("beanstalk://localhost:11300")
18
+ end
19
+
20
+ # By default after receiving QUIT juggler will wait up to 2s for running
21
+ # jobs to complete before killing them
22
+ def shutdown_grace_timeout
23
+ @shutdown_grace_timeout || 2
11
24
  end
12
25
 
13
26
  def logger
14
27
  @logger ||= begin
28
+ require 'logger'
15
29
  logger = Logger.new(STDOUT)
16
30
  logger.level = Logger::WARN
17
31
  logger.debug("Created logger")
@@ -22,15 +36,14 @@ class Juggler
22
36
  def throw(method, params, options = {})
23
37
  # TODO: Do some checking on the method
24
38
  connection.use(method.to_s)
25
-
26
- priority = options[:priority] || 50
27
- delay = 0
28
- # Add 2s because we want to handle the timeout before beanstalk does
29
- ttr = (options[:ttr] || 60) + 2
30
-
31
- connection.put(Marshal.dump(params), priority, delay, ttr)
39
+ connection.put(Marshal.dump(params), options)
32
40
  end
33
41
 
42
+ # Strategy block: should return a deferrable object (so that juggler can
43
+ # apply callbacks and errbacks). You should note that this deferrable may
44
+ # be failed by juggler if the job timeout is exceeded, and therefore you
45
+ # are responsible for cleaning up your state (for example cancelling any
46
+ # timers which you have created)
34
47
  def juggle(method, concurrency = 1, &strategy)
35
48
  Runner.new(method, concurrency, strategy).run
36
49
  end
@@ -38,9 +51,30 @@ class Juggler
38
51
  private
39
52
 
40
53
  def connection
41
- @connection ||= Beanstalk::Pool.new(hosts)
54
+ @connection ||= EMJack::Connection.new({
55
+ :host => server.host,
56
+ :port => server.port
57
+ })
42
58
  end
43
59
  end
44
60
  end
45
61
 
62
+ # Default exception handler
63
+ Juggler.exception_handler = Proc.new do |e|
64
+ Juggler.logger.error "Error running job: #{e.message} (#{e.class})"
65
+ Juggler.logger.debug e.backtrace.join("\n")
66
+ end
67
+
68
+ # Default backoff function
69
+ Juggler.backoff_function = Proc.new do |job_runner, job_stats|
70
+ # 2, 3, 4, 6, 8, 11, 15, 20, ..., 72465
71
+ delay = ([1, job_stats["delay"] * 1.3].max).ceil
72
+ if delay > 60 * 60 * 24
73
+ job_runner.bury
74
+ else
75
+ job_runner.release(delay)
76
+ end
77
+ end
78
+
46
79
  Juggler.autoload 'Runner', 'juggler/runner'
80
+ Juggler.autoload 'JobRunner', 'juggler/job_runner'
@@ -0,0 +1,144 @@
1
+ require 'juggler/state_machine'
2
+
3
+ class Juggler
4
+ class JobRunner
5
+ include StateMachine
6
+
7
+ state :new
8
+ state :running, :pre => :fetch_stats, :enter => :run_strategy
9
+ state :succeeded, :enter => :delete
10
+ state :timed_out, :enter => [:timeout_strategy, :backoff]
11
+ state :failed, :enter => :delete
12
+ state :retried, :enter => :backoff
13
+ state :done
14
+
15
+ attr_reader :job
16
+
17
+ def initialize(job, params, strategy)
18
+ @job = job
19
+ @params = params
20
+ @strategy = strategy
21
+ Juggler.logger.debug {
22
+ "#{to_s}: New job with body: #{params.inspect}"
23
+ }
24
+ @_state = :new
25
+ end
26
+
27
+ def run
28
+ change_state(:running)
29
+ end
30
+
31
+ def check_for_timeout
32
+ if state == :running
33
+ if (time_left = @end_time - Time.now) < 1
34
+ Juggler.logger.info("#{to_s}: Timed out (#{time_left}s left)")
35
+ change_state(:timed_out)
36
+ end
37
+ end
38
+ end
39
+
40
+ def to_s
41
+ "Job #{@job.jobid}"
42
+ end
43
+
44
+ def release(delay = 0)
45
+ Juggler.logger.debug { "#{to_s}: releasing" }
46
+ release_def = job.release(:delay => delay)
47
+ release_def.callback {
48
+ Juggler.logger.info { "#{to_s}: released for retry in #{delay}s" }
49
+ change_state(:done)
50
+ }
51
+ release_def.errback {
52
+ Juggler.logger.error { "#{to_s}: release failed (could not release)" }
53
+ change_state(:done)
54
+ }
55
+ end
56
+
57
+ def bury
58
+ Juggler.logger.warn { "#{to_s}: burying" }
59
+ release_def = job.bury(100000) # Set priority till em-jack fixed
60
+ release_def.callback {
61
+ change_state(:done)
62
+ }
63
+ release_def.errback {
64
+ change_state(:done)
65
+ }
66
+ end
67
+
68
+ def delete
69
+ dd = job.delete
70
+ dd.callback do
71
+ Juggler.logger.debug "#{to_s}: deleted"
72
+ change_state(:done)
73
+ end
74
+ dd.errback do
75
+ Juggler.logger.debug "#{to_s}: delete operation failed"
76
+ change_state(:done)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # Retrives job stats from beanstalkd
83
+ def fetch_stats
84
+ dd = EM::DefaultDeferrable.new
85
+
86
+ Juggler.logger.debug { "#{to_s}: Fetching stats" }
87
+
88
+ stats_def = job.stats
89
+ stats_def.callback do |stats|
90
+ @stats = stats
91
+ @end_time = Time.now + stats["time-left"]
92
+ Juggler.logger.debug { "#{to_s} stats: #{stats.inspect}"}
93
+ dd.succeed
94
+ end
95
+ stats_def.errback {
96
+ Juggler.logger.error { "#{to_s}: Fetching stats failed" }
97
+ dd.fail
98
+ }
99
+
100
+ dd
101
+ end
102
+
103
+ # Wraps running the actual job.
104
+ # Returns a deferrable that fails if there is an exception calling the
105
+ # strategy or if the strategy triggers errback
106
+ def run_strategy
107
+ begin
108
+ sd = EM::DefaultDeferrable.new
109
+ @strategy.call(sd, @params)
110
+ sd.callback {
111
+ change_state(:succeeded)
112
+ }
113
+ sd.errback { |e|
114
+ # timed_out error is already handled
115
+ next if e == :timed_out
116
+
117
+ if e == :no_retry
118
+ # Do not schedule the job to be retried
119
+ change_state(:failed)
120
+ elsif e.kind_of?(Exception)
121
+ # Handle exception and schedule for retry
122
+ Juggler.exception_handler.call(e)
123
+ change_state(:retried)
124
+ else
125
+ Juggler.logger.debug { "#{to_s}: failed with #{e.inspect}" }
126
+ change_state(:retried)
127
+ end
128
+ }
129
+ @strategy_deferrable = sd
130
+ rescue => e
131
+ Juggler.exception_handler.call(e)
132
+ change_state(:retried)
133
+ end
134
+ end
135
+
136
+ def timeout_strategy
137
+ @strategy_deferrable.fail(:timed_out)
138
+ end
139
+
140
+ def backoff
141
+ Juggler.backoff_function.call(self, @stats)
142
+ end
143
+ end
144
+ end
@@ -1,90 +1,201 @@
1
1
  class Juggler
2
+ # Stopping: This is rather complex. The point of the __STOP__ malarkey it to
3
+ # unblock a blocking reserve so that delete and release commands can be
4
+ # actioned on the currently running jobs before shutdown. Also a
5
+ # Juggler.shutdown_grace_timeout period is availble for jobs to complete before the
6
+ # eventmachine is stopped
7
+ #
2
8
  class Runner
3
9
  class << self
4
- def start
5
- @started ||= begin
6
- Signal.trap('INT') { EM.stop }
7
- Signal.trap('TERM') { EM.stop }
10
+ def start(runner)
11
+ @runners ||= []
12
+ @runners << runner
13
+
14
+ @signals_setup ||= begin
15
+ %w{INT TERM}.each do |sig|
16
+ Signal.trap(sig) {
17
+ stop_all_runners_with_grace
18
+ }
19
+ end
8
20
  true
9
21
  end
10
22
  end
23
+
24
+ private
25
+
26
+ def stop_all_runners_with_grace
27
+ # Trigger each runner to shut down
28
+ @runners.each { |r| r.stop }
29
+
30
+ Juggler.logger.info {
31
+ "Giving processes #{Juggler.shutdown_grace_timeout}s grace period to exit"
32
+ }
33
+
34
+ EM::PeriodicTimer.new(0.1) {
35
+ if !@runners.any? { |r| r.running? }
36
+ Juggler.logger.info "Exited cleanly"
37
+ EM.stop
38
+ end
39
+ }
40
+
41
+ EM::Timer.new(Juggler.shutdown_grace_timeout) do
42
+ Juggler.logger.info {
43
+ "Force exited after #{Juggler.shutdown_grace_timeout}s with tasks running"
44
+ }
45
+ EM.stop
46
+ end
47
+ end
11
48
  end
12
49
 
13
50
  def initialize(method, concurrency, strategy)
14
51
  @strategy = strategy
15
52
  @concurrency = concurrency
16
53
  @queue = method.to_s
54
+
17
55
  @running = []
56
+ @reserved = false
57
+ end
58
+
59
+ # We potentially need to issue a new reserve call after a job is reserved
60
+ # (if we're not at the concurrency limit), and after a job completes
61
+ # (unless we're already reserving)
62
+ def reserve_if_necessary
63
+ if @on && @connection.connected? && !@reserved && @running.size < @concurrency
64
+ Juggler.logger.debug "#{to_s}: Reserving"
65
+ reserve
66
+ end
18
67
  end
19
68
 
20
69
  def reserve
21
- beanstalk_job = connection.reserve(0)
70
+ @reserved = true
71
+
72
+ reserve_call = connection.reserve
73
+
74
+ reserve_call.callback do |job|
75
+ @reserved = false
22
76
 
23
- begin
24
- params = Marshal.load(beanstalk_job.body)
25
- rescue => e
26
- handle_exception(e, "Exception unmarshaling #{@queue} job")
27
- beanstalk_job.delete
28
- return
29
- end
77
+ begin
78
+ params = Marshal.load(job.body)
79
+ rescue => e
80
+ handle_exception(e, "#{to_s}: Exception unmarshaling #{@queue} job")
81
+ connection.delete(job)
82
+ next
83
+ end
84
+
85
+ if params == "__STOP__"
86
+ connection.delete(job)
87
+ next
88
+ end
89
+
90
+ job_runner = JobRunner.new(job, params, @strategy)
91
+
92
+ @running << job_runner
30
93
 
31
- begin
32
- job = @strategy.call(params)
33
- rescue => e
34
- handle_exception(e, "Exception calling #{@queue} strategy")
35
- beanstalk_job.decay
36
- return
37
- end
94
+ Juggler.logger.debug {
95
+ "#{to_s}: Excecuting #{@running.size} jobs"
96
+ }
38
97
 
39
- EM::Timer.new(beanstalk_job.ttr - 2) do
40
- job.fail(:timeout)
41
- end
98
+ # We may reserve after job is running (after fetching stats)
99
+ job_runner.bind(:running) {
100
+ reserve_if_necessary
101
+ }
42
102
 
43
- @running << job
44
- job.callback do
45
- @running.delete(job)
46
- beanstalk_job.delete
103
+ # Also may reserve when a job is done
104
+ job_runner.bind(:done) {
105
+ @running.delete(job_runner)
106
+ reserve_if_necessary
107
+ }
108
+
109
+ job_runner.run
47
110
  end
48
- job.errback do |error|
49
- Juggler.logger.info("#{@queue} job failed: #{error}")
50
- @running.delete(job)
51
- # Built in exponential backoff
52
- beanstalk_job.decay
111
+
112
+ reserve_call.errback do |error|
113
+ @reserved = false
114
+
115
+ if error == :deadline_soon
116
+ # This doesn't necessarily mean that a job has taken too long, it is
117
+ # quite likely that the blocking reserve is just stopping jobs from
118
+ # being deleted
119
+ Juggler.logger.debug "#{to_s}: Reserve terminated (deadline_soon)"
120
+
121
+ check_all_reserved_jobs.callback {
122
+ reserve_if_necessary
123
+ }
124
+ elsif error == :disconnected
125
+ Juggler.logger.warn "#{to_s}: Reserve terminated (beanstalkd disconnected)"
126
+ else
127
+ Juggler.logger.error "#{to_s}: Unexpected error: #{error}"
128
+ reserve_if_necessary
129
+ end
53
130
  end
54
- rescue Beanstalk::TimedOut
55
- rescue Beanstalk::NotConnected
56
- Juggler.logger.fatal "Could not connect any beanstalk hosts. " \
57
- "Retrying in 1s."
58
- sleep 1
59
- rescue => e
60
- handle_exception(e, "Unhandled exception")
61
- beanstalk_job.delete if beanstalk_job
62
131
  end
63
132
 
64
133
  def run
65
- EM.add_periodic_timer do
66
- reserve if spare_slot?
67
- end
68
- Runner.start
134
+ @on = true
135
+ Runner.start(self)
136
+ # Creates beanstalkd connection - reserve happens on connect
137
+ connection
69
138
  end
70
139
 
71
- private
140
+ def stop
141
+ @on = false
72
142
 
73
- def spare_slot?
74
- @running.size < @concurrency
143
+ # See class documentation on stopping
144
+ if @reserved
145
+ Juggler.throw(@queue, "__STOP__")
146
+ end
147
+ end
148
+
149
+ def running?
150
+ @running.size > 0
151
+ end
152
+
153
+ def to_s
154
+ "Tube #{@queue}"
75
155
  end
76
156
 
157
+ private
158
+
77
159
  def handle_exception(e, message)
78
- Juggler.logger.error "#{message}: #{e.class} #{e.message}"
160
+ Juggler.logger.error "#{message}: #{e.message} (#{e.class})"
79
161
  Juggler.logger.debug e.backtrace.join("\n")
80
162
  end
81
163
 
82
164
  def connection
83
- @pool ||= begin
84
- pool = Beanstalk::Pool.new(Juggler.hosts)
85
- pool.watch(@queue)
86
- pool
165
+ @connection ||= begin
166
+ c = EMJack::Connection.new({
167
+ :host => Juggler.server.host,
168
+ :port => Juggler.server.port,
169
+ })
170
+ c.on_connect {
171
+ c.watch(@queue)
172
+ reserve_if_necessary
173
+ }
174
+ c
175
+ end
176
+ end
177
+
178
+ # Iterates over all jobs reserved on this connection and fails them if
179
+ # they're within 1s of their timeout. Returns a callback which completes
180
+ # when all jobs have been checked
181
+ def check_all_reserved_jobs
182
+ dd = EM::DefaultDeferrable.new
183
+
184
+ @running.each do |job_runner|
185
+ job_runner.check_for_timeout
186
+ end
187
+
188
+ # Wait 1s before reserving or we'll just get DEALINE_SOON again
189
+ # "If the client issues a reserve command during the safety margin,
190
+ # <snip>, the server will respond with: DEADLINE_SOON"
191
+ #
192
+ # In theory, one should not need to do this since reserve will already
193
+ # be triggered as a callback on the job that has timed out
194
+ EM::Timer.new(1) do
195
+ dd.succeed
87
196
  end
197
+
198
+ dd
88
199
  end
89
200
  end
90
201
  end
@@ -0,0 +1,81 @@
1
+ # Special eventmachine state machine
2
+ #
3
+ # Callbacks are defined for before :exit, :pre enter and after :enter
4
+ # Asynchrous callbacks are not properly supported yet - only :pre must return
5
+ # a deferrable which will continue the callback chain on complete
6
+ #
7
+ # state :foobar, :enter => 'get_http'
8
+ #
9
+ module Juggler::StateMachine
10
+ def self.included(klass)
11
+ klass.extend(ClassMethods)
12
+ end
13
+
14
+ def state
15
+ @_state
16
+ end
17
+
18
+ def change_state(new_state)
19
+ old_state = @_state
20
+
21
+ Juggler.logger.debug "#{to_s}: Changing state: #{old_state} to #{new_state}"
22
+
23
+ return nil if old_state == new_state
24
+
25
+ raise "#{to_s}: Invalid state #{new_state}" unless self.class.states[new_state]
26
+
27
+ if method = self.class.states[new_state][:pre]
28
+ deferable = self.send(method)
29
+ deferable.callback {
30
+ run_synchronous_callbacks(old_state, new_state)
31
+ }
32
+ deferable.errback {
33
+ Juggler.logger.warn "#{to_s}: State change aborted - pre failed"
34
+ }
35
+ else
36
+ run_synchronous_callbacks(old_state, new_state)
37
+ end
38
+
39
+ return true
40
+ end
41
+
42
+ def bind(state, &callback)
43
+ @on_state ||= Hash.new { |h, k| h[k] = [] }
44
+ @on_state[state] << callback
45
+ end
46
+
47
+ private
48
+
49
+ def run_synchronous_callbacks(old_state, new_state)
50
+ catch :halt do
51
+ if callbacks = self.class.states[old_state][:exit]
52
+ [callbacks].flatten.each { |c| self.send(c) }
53
+ end
54
+
55
+ set_state(new_state)
56
+
57
+ if callbacks = self.class.states[new_state][:enter]
58
+ [callbacks].flatten.each { |c| self.send(c) }
59
+ end
60
+ end
61
+ end
62
+
63
+ def set_state(new_state)
64
+ @_state = new_state
65
+
66
+ if @on_state && (callbacks = @on_state[new_state])
67
+ callbacks.each { |c| c.call(self) }
68
+ end
69
+ end
70
+
71
+ module ClassMethods
72
+ def states
73
+ @_states
74
+ end
75
+
76
+ def state(name, callbacks = {})
77
+ @_states ||= {}
78
+ @_states[name] = callbacks
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,100 @@
1
+ require File.expand_path('../../spec_helper', __FILE__)
2
+
3
+ describe "Juggler" do
4
+ include EM::SpecHelper
5
+
6
+ before :each do
7
+ # Reset state
8
+ Juggler.instance_variable_set(:@connection, nil)
9
+ Juggler::Runner.instance_variable_set(:@runners, nil)
10
+
11
+ # Start clean beanstalk instance for each test
12
+ Juggler.server = "beanstalk://localhost:10001"
13
+ system "beanstalkd -p 10001 &"
14
+ sleep 0.1
15
+ end
16
+
17
+ after :each do
18
+ # TODO: Use pid
19
+ system "killall beanstalkd"
20
+ end
21
+
22
+ it "should successfully excecute one job" do
23
+ em(1) do
24
+ params_for_jobs_received = []
25
+ Juggler.juggle(:some_task, 1) { |df, params|
26
+ params_for_jobs_received << params
27
+ df.succeed_later_with(nil)
28
+ }
29
+ Juggler.throw(:some_task, {:some => "params"})
30
+
31
+ EM.add_timer(0.1) {
32
+ params_for_jobs_received.should == [{:some => "params"}]
33
+ done
34
+ }
35
+ end
36
+ end
37
+
38
+ it "should run correct number of jobs concurrently" do
39
+ em(1) do
40
+ params_for_jobs_received = []
41
+ Juggler.juggle(:some_task, 2) { |df, params|
42
+ params_for_jobs_received << params
43
+ df.succeed_later_with(nil, 0.2)
44
+ }
45
+
46
+ 10.times { |i| Juggler.throw(:some_task, i) }
47
+
48
+ EM.add_timer(0.3) {
49
+ # After 0.3 seconds, 2 jobs should have completed, and 2 more started
50
+ params_for_jobs_received.should == [0, 1, 2, 3]
51
+ done
52
+ }
53
+ end
54
+ end
55
+
56
+ it "should stop em after jobs completed when signaled to QUIT" do
57
+ job_finished = false
58
+ job_started = false
59
+ em(1) do
60
+ Juggler.juggle(:some_task, 1) { |df, params|
61
+ job_started = true
62
+ EM.add_timer(0.2) {
63
+ job_finished = true
64
+ df.succeed
65
+ }
66
+ }
67
+ Juggler.throw(:some_task, 'foo')
68
+ EM.add_timer(0.1) {
69
+ job_started.should == true
70
+ job_finished.should == false
71
+ Juggler::Runner.send(:stop_all_runners_with_grace)
72
+ }
73
+ end
74
+ job_finished.should == true
75
+ end
76
+
77
+ it "should kill jobs that do not complete within shutdown_grace_timeout" do
78
+ job_finished = false
79
+ job_started = false
80
+ em(1) do
81
+ Juggler.shutdown_grace_timeout = 0.1
82
+ Juggler.juggle(:some_task, 1) { |params|
83
+ dd = EM::DefaultDeferrable.new
84
+ job_started = true
85
+ EM.add_timer(1) {
86
+ job_finished = true
87
+ dd.succeed
88
+ }
89
+ dd
90
+ }
91
+ Juggler.throw(:some_task, 'foo')
92
+ EM.add_timer(0.1) {
93
+ job_started.should == true
94
+ job_finished.should == false
95
+ Juggler::Runner.send(:stop_all_runners_with_grace)
96
+ }
97
+ end
98
+ job_finished.should == false
99
+ end
100
+ end
@@ -1,3 +1,53 @@
1
1
  $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
2
 
3
3
  require 'juggler'
4
+
5
+ Juggler.logger.level = Logger::FATAL
6
+
7
+ require 'em-spec/rspec'
8
+
9
+ module EM
10
+ class ChainableDeferrable
11
+ include Deferrable
12
+
13
+ def callback(&block)
14
+ super
15
+ self
16
+ end
17
+
18
+ def errback(&block)
19
+ super
20
+ self
21
+ end
22
+ end
23
+
24
+ module Deferrable
25
+ def succeed_later_with(callback, time = 0)
26
+ EM.add_timer(time) {
27
+ succeed(callback)
28
+ }
29
+ end
30
+
31
+ def fail_later_with(callback, time = 0)
32
+ EM.add_timer(time) {
33
+ fail(callback)
34
+ }
35
+ end
36
+ end
37
+ end
38
+
39
+ def stub_deferrable(callback, time = 0)
40
+ d = EM::ChainableDeferrable.new
41
+ EM.add_timer(time) {
42
+ d.succeed(callback)
43
+ }
44
+ d
45
+ end
46
+
47
+ def stub_failing_deferrable(callback, time = 0)
48
+ d = EM::ChainableDeferrable.new
49
+ EM.add_timer(time) {
50
+ d.fail(callback)
51
+ }
52
+ d
53
+ end
@@ -0,0 +1,192 @@
1
+ require File.expand_path('../../spec_helper', __FILE__)
2
+
3
+ # This spec describes the behaviour of job runner by mocking the interface to
4
+ # beanstalk as exposed by em-jack
5
+ #
6
+ describe Juggler::JobRunner do
7
+ include EM::SpecHelper
8
+
9
+ it "should run job and delete in the success case" do
10
+ em(1) {
11
+ job = mock(:job, {
12
+ :jobid => 1,
13
+ :stats => stub_deferrable({"time-left" => 2})
14
+ })
15
+
16
+ job.should_receive(:delete).and_return(stub_deferrable(nil))
17
+
18
+ strategy = lambda { |df, params|
19
+ df.succeed_later_with(nil, 0.2)
20
+ }
21
+
22
+ jobrunner = Juggler::JobRunner.new(job, {}, strategy)
23
+ jobrunner.run
24
+
25
+ # To check that check_for_timeout doesn't timeout in the sucess case
26
+ EM.add_timer(0.1) {
27
+ jobrunner.check_for_timeout
28
+ }
29
+
30
+ jobrunner.bind(:succeeded) {
31
+ @state = :succeeded
32
+ }
33
+
34
+ jobrunner.bind(:done) {
35
+ @state.should == :succeeded
36
+ done
37
+ }
38
+ }
39
+ end
40
+
41
+ it "should release job for retry if exception calling strategy" do
42
+ em(1) {
43
+ job = mock(:job, {
44
+ :jobid => 1,
45
+ :stats => stub_deferrable({"time-left" => 2, "delay" => 0})
46
+ })
47
+
48
+ job.should_receive(:release).with({:delay => 1}).
49
+ and_return(stub_deferrable(nil))
50
+
51
+ strategy = lambda { |df, params|
52
+ raise 'strategy blows up'
53
+ }
54
+
55
+ jobrunner = Juggler::JobRunner.new(job, {}, strategy)
56
+ jobrunner.run
57
+
58
+ jobrunner.bind(:retried) {
59
+ @state = :retried
60
+ }
61
+
62
+ jobrunner.bind(:done) {
63
+ @state.should == :retried
64
+ done
65
+ }
66
+ }
67
+ end
68
+
69
+ it "should release job for retry and call exception handler if job deferrable fails with an exception" do
70
+ asserts = 0
71
+
72
+ em(1) {
73
+ job = mock(:job, {
74
+ :jobid => 1,
75
+ :stats => stub_deferrable({"time-left" => 2, "delay" => 0})
76
+ })
77
+
78
+ job.should_receive(:release).with({:delay => 1}).
79
+ and_return(stub_deferrable(nil))
80
+
81
+ Juggler.exception_handler = lambda { |e|
82
+ e.message.should == "FAIL"
83
+ asserts += 1
84
+ }
85
+
86
+ strategy = lambda { |df, params|
87
+ EM.next_tick {
88
+ df.fail(RuntimeError.new("FAIL"))
89
+ }
90
+ }
91
+
92
+ jobrunner = Juggler::JobRunner.new(job, {}, strategy)
93
+ jobrunner.run
94
+
95
+ jobrunner.bind(:retried) {
96
+ asserts += 1
97
+ }
98
+
99
+ jobrunner.bind(:done) {
100
+ asserts.should == 2
101
+ done
102
+ }
103
+ }
104
+ end
105
+
106
+ it "should release job for retry if job fails with no arguments" do
107
+ em(1) {
108
+ job = mock(:job, {
109
+ :jobid => 1,
110
+ :stats => stub_deferrable({"time-left" => 2, "delay" => 0})
111
+ })
112
+
113
+ job.should_receive(:release).with({:delay => 1}).
114
+ and_return(stub_deferrable(nil))
115
+
116
+ strategy = lambda { |df, params|
117
+ df.fail_later_with(nil)
118
+ }
119
+
120
+ jobrunner = Juggler::JobRunner.new(job, {}, strategy)
121
+ jobrunner.run
122
+
123
+ jobrunner.bind(:retried) {
124
+ @state = :retried
125
+ }
126
+
127
+ jobrunner.bind(:done) {
128
+ @state.should == :retried
129
+ done
130
+ }
131
+ }
132
+ end
133
+
134
+ it "should fail and delete job if job fails with :no_retry" do
135
+ em(1) {
136
+ job = mock(:job, {
137
+ :jobid => 1,
138
+ :stats => stub_deferrable({"time-left" => 2, "delay" => 0})
139
+ })
140
+
141
+ job.should_receive(:delete).and_return(stub_deferrable(nil))
142
+
143
+ strategy = lambda { |df, params|
144
+ df.fail_later_with(:no_retry)
145
+ }
146
+
147
+ jobrunner = Juggler::JobRunner.new(job, {}, strategy)
148
+ jobrunner.run
149
+
150
+ jobrunner.bind(:failed) {
151
+ @state = :failed
152
+ }
153
+
154
+ jobrunner.bind(:done) {
155
+ @state.should == :failed
156
+ done
157
+ }
158
+ }
159
+ end
160
+
161
+ it "should retry job if strategy deferrable exceeds timeout" do
162
+ em(1) {
163
+ job = mock(:job, {
164
+ :jobid => 1,
165
+ :stats => stub_deferrable({"time-left" => 1, "delay" => 0})
166
+ })
167
+
168
+ job.should_receive(:release).with({:delay => 1}).
169
+ and_return(stub_deferrable(nil))
170
+
171
+ strategy = lambda { |df, params|
172
+ df.succeed_later_with(nil, 2)
173
+ }
174
+
175
+ jobrunner = Juggler::JobRunner.new(job, {}, strategy)
176
+ jobrunner.run
177
+
178
+ EM.add_timer(0.1) {
179
+ jobrunner.check_for_timeout
180
+ }
181
+
182
+ jobrunner.bind(:timed_out) {
183
+ @state = :timed_out
184
+ }
185
+
186
+ jobrunner.bind(:done) {
187
+ @state.should == :timed_out
188
+ done
189
+ }
190
+ }
191
+ end
192
+ end
metadata CHANGED
@@ -1,71 +1,99 @@
1
- --- !ruby/object:Gem::Specification
1
+ --- !ruby/object:Gem::Specification
2
2
  name: juggler
3
- version: !ruby/object:Gem::Version
4
- version: 0.0.2
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
5
6
  platform: ruby
6
- authors:
7
+ authors:
7
8
  - Martyn Loughran
8
9
  autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
-
12
- date: 2009-10-21 00:00:00 +01:00
13
- default_executable:
14
- dependencies: []
15
-
12
+ date: 2012-08-14 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: em-jack
16
+ requirement: &70241134047560 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.1.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *70241134047560
25
+ - !ruby/object:Gem::Dependency
26
+ name: em-spec
27
+ requirement: &70241134047120 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *70241134047120
36
+ - !ruby/object:Gem::Dependency
37
+ name: rake
38
+ requirement: &70241134046660 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *70241134046660
16
47
  description: Juggling background jobs with EventMachine and Beanstalkd
17
- email: me@mloughran.com
48
+ email:
49
+ - me@mloughran.com
18
50
  executables: []
19
-
20
51
  extensions: []
21
-
22
- extra_rdoc_files:
23
- - LICENSE
24
- - README.md
25
- files:
52
+ extra_rdoc_files: []
53
+ files:
26
54
  - .gitignore
55
+ - Gemfile
27
56
  - LICENSE
28
57
  - README.md
29
58
  - Rakefile
30
59
  - VERSION
60
+ - examples/app.rb
31
61
  - examples/test_consumer.rb
32
62
  - examples/test_producer.rb
63
+ - juggler.gemspec
33
64
  - lib/juggler.rb
65
+ - lib/juggler/job_runner.rb
34
66
  - lib/juggler/runner.rb
35
- - spec/juggler_spec.rb
67
+ - lib/juggler/state_machine.rb
68
+ - spec/integration/juggler_spec.rb
36
69
  - spec/spec_helper.rb
37
- has_rdoc: true
38
- homepage: http://github.com/mloughran/juggler
70
+ - spec/unit/job_runner_spec.rb
71
+ homepage: https://github.com/mloughran/juggler
39
72
  licenses: []
40
-
41
73
  post_install_message:
42
- rdoc_options:
43
- - --charset=UTF-8
44
- require_paths:
74
+ rdoc_options: []
75
+ require_paths:
45
76
  - lib
46
- required_ruby_version: !ruby/object:Gem::Requirement
47
- requirements:
48
- - - ">="
49
- - !ruby/object:Gem::Version
50
- version: "0"
51
- version:
52
- required_rubygems_version: !ruby/object:Gem::Requirement
53
- requirements:
54
- - - ">="
55
- - !ruby/object:Gem::Version
56
- version: "0"
57
- version:
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ none: false
79
+ requirements:
80
+ - - ! '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ none: false
85
+ requirements:
86
+ - - ! '>='
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
58
89
  requirements: []
59
-
60
90
  rubyforge_project:
61
- rubygems_version: 1.3.5
91
+ rubygems_version: 1.8.10
62
92
  signing_key:
63
93
  specification_version: 3
64
94
  summary: Juggling background jobs with EventMachine and Beanstalkd
65
- test_files:
66
- - spec/juggler_spec.rb
67
- - spec/runner_spec.rb
95
+ test_files:
96
+ - spec/integration/juggler_spec.rb
68
97
  - spec/spec_helper.rb
69
- - examples/app.rb
70
- - examples/test_consumer.rb
71
- - examples/test_producer.rb
98
+ - spec/unit/job_runner_spec.rb
99
+ has_rdoc:
@@ -1,7 +0,0 @@
1
- require File.dirname(__FILE__) + '/spec_helper'
2
-
3
- describe Juggler do
4
- it "should put jobs on queue" do
5
- Juggler.throw('method', {:foo => "bar"})
6
- end
7
- end
@@ -1,21 +0,0 @@
1
- require File.dirname(__FILE__) + '/spec_helper'
2
-
3
- describe Juggler::Runner do
4
- it "should delete job from beanstalkd when successful" do
5
- @mock_beanstalk = mock(Beanstalk::Pool)
6
- @mock_job = mock(Beanstalk::Job, :body => "foo")
7
- Beanstalk::Pool.should_receive(:new).and_return(@mock_beanstalk)
8
-
9
- @mock_job.should_receive(:delete)
10
-
11
- Juggler::Runner.new(:method, 1, lambda do
12
- deferrable = EM::DefaultDeferrable.new
13
- deferrable.set_deferred_status :succeeded, nil
14
- deferrable
15
- end).run
16
-
17
- sleep 1
18
- end
19
-
20
- it "should retry job if not successful"
21
- end