resque-scheduler 2.0.0 → 2.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.

Potentially problematic release.


This version of resque-scheduler might be problematic. Click here for more details.

data/README.markdown CHANGED
@@ -82,7 +82,7 @@ to know.
82
82
  # need to require your jobs. This can be an advantage since it's
83
83
  # less code that resque-scheduler needs to know about. But in a small
84
84
  # project, it's usually easier to just include you job classes here.
85
- # So, someting like this:
85
+ # So, something like this:
86
86
  require 'jobs'
87
87
  end
88
88
  end
@@ -99,17 +99,6 @@ any nonempty value, they will take effect. `VERBOSE` simply dumps more output
99
99
  to stdout. `MUTE` does the opposite and silences all output. `MUTE`
100
100
  supersedes `VERBOSE`.
101
101
 
102
- NOTE: You DO NOT want to run >1 instance of the scheduler. Doing so will
103
- result in the same job being queued more than once. You only need one
104
- instance of the scheduler running per resque instance (regardless of number
105
- of machines).
106
-
107
- If the scheduler process goes down for whatever reason, the delayed items
108
- that should have fired during the outage will fire once the scheduler process
109
- is started back up again (regardless of it being on a new machine). Missed
110
- scheduled jobs, however, will not fire upon recovery of the scheduler process.
111
-
112
-
113
102
 
114
103
  ### Delayed jobs
115
104
 
@@ -280,6 +269,25 @@ custom job class to support the #scheduled method:
280
269
  end
281
270
  end
282
271
 
272
+ ### Redundancy and Fail-Over
273
+
274
+ *>= 2.0.1 only. Prior to 2.0.1, it is not recommended to run multiple resque-scheduler processes and will result in duplicate jobs.*
275
+
276
+ You may want to have resque-scheduler running on multiple machines for
277
+ redudancy. Electing a master and failover is built in and default. Simply
278
+ run resque-scheduler on as many machine as you want pointing to the same
279
+ redis instance and schedule. The scheduler processes will use redis to
280
+ elect a master process and detect failover when the master dies. Precautions are
281
+ taken to prevent jobs from potentially being queued twice during failover even
282
+ when the clocks of the scheduler machines are slightly out of sync (or load affects
283
+ scheduled job firing time). If you want the gory details, look at Resque::SchedulerLocking.
284
+
285
+ If the scheduler process(es) goes down for whatever reason, the delayed items
286
+ that should have fired during the outage will fire once the scheduler process
287
+ is started back up again (regardless of it being on a new machine). Missed
288
+ scheduled jobs, however, will not fire upon recovery of the scheduler process.
289
+ Think of scheduled (recurring) jobs as cron jobs - if you stop cron, it doesn't fire
290
+ missed jobs once it starts back up.
283
291
 
284
292
 
285
293
  ### resque-web Additions
@@ -1,11 +1,13 @@
1
1
  require 'rufus/scheduler'
2
2
  require 'thwait'
3
+ require 'resque/scheduler_locking'
3
4
 
4
5
  module Resque
5
6
 
6
7
  class Scheduler
7
8
 
8
9
  extend Resque::Helpers
10
+ extend Resque::SchedulerLocking
9
11
 
10
12
  class << self
11
13
 
@@ -15,7 +17,7 @@ module Resque
15
17
  # If set, produces no output
16
18
  attr_accessor :mute
17
19
 
18
- # If set, will try to update the schulde in the loop
20
+ # If set, will try to update the schedule in the loop
19
21
  attr_accessor :dynamic
20
22
 
21
23
  # Amount of time in seconds to sleep between polls of the delayed
@@ -47,17 +49,20 @@ module Resque
47
49
 
48
50
  # Now start the scheduling part of the loop.
49
51
  loop do
50
- begin
51
- handle_delayed_items
52
- update_schedule if dynamic
53
- rescue Errno::EAGAIN, Errno::ECONNRESET => e
54
- warn e.message
52
+ if is_master?
53
+ begin
54
+ handle_delayed_items
55
+ update_schedule if dynamic
56
+ rescue Errno::EAGAIN, Errno::ECONNRESET => e
57
+ warn e.message
58
+ end
55
59
  end
56
60
  poll_sleep
57
61
  end
58
62
 
59
63
  # never gets here.
60
64
  end
65
+
61
66
 
62
67
  # For all signals, set the shutdown flag and wait for current
63
68
  # poll/enqueing to finish (should be almost istant). In the
@@ -133,8 +138,10 @@ module Resque
133
138
  if !config[interval_type].nil? && config[interval_type].length > 0
134
139
  args = optionizate_interval_value(config[interval_type])
135
140
  @@scheduled_jobs[name] = rufus_scheduler.send(interval_type, *args) do
136
- log! "queueing #{config['class']} (#{name})"
137
- handle_errors { enqueue_from_config(config) }
141
+ if is_master?
142
+ log! "queueing #{config['class']} (#{name})"
143
+ handle_errors { enqueue_from_config(config) }
144
+ end
138
145
  end
139
146
  interval_defined = true
140
147
  break
@@ -169,7 +176,8 @@ module Resque
169
176
  item = nil
170
177
  begin
171
178
  handle_shutdown do
172
- if item = Resque.next_item_for_timestamp(timestamp)
179
+ # Continually check that it is still the master
180
+ if is_master? && item = Resque.next_item_for_timestamp(timestamp)
173
181
  log "queuing #{item['class']} [delayed]"
174
182
  handle_errors { enqueue_from_config(item) }
175
183
  end
@@ -218,7 +226,14 @@ module Resque
218
226
  # one app that schedules for another
219
227
  if Class === klass
220
228
  ResqueScheduler::Plugin.run_before_delayed_enqueue_hooks(klass, *params)
221
- Resque.enqueue_to(queue, klass, *params)
229
+
230
+ # If the class is a custom job class, call self#scheduled on it. This allows you to do things like
231
+ # Resque.enqueue_at(timestamp, CustomJobClass). Otherwise, pass off to Resque.
232
+ if klass.respond_to?(:scheduled)
233
+ klass.scheduled(queue, klass_name, *params)
234
+ else
235
+ Resque.enqueue_to(queue, klass, *params)
236
+ end
222
237
  else
223
238
  # This will not run the before_hooks in rescue, but will at least
224
239
  # queue the job.
@@ -281,7 +296,10 @@ module Resque
281
296
  # Sets the shutdown flag, exits if sleeping
282
297
  def shutdown
283
298
  @shutdown = true
284
- exit if @sleeping
299
+ if @sleeping
300
+ release_master_lock!
301
+ exit
302
+ end
285
303
  end
286
304
 
287
305
  def log!(msg)
@@ -0,0 +1,3 @@
1
+ %w[base basic resilient].each do |file|
2
+ require "resque/scheduler/lock/#{file}"
3
+ end
@@ -0,0 +1,52 @@
1
+ module Resque
2
+ class Scheduler
3
+ module Lock
4
+ class Base
5
+ attr_reader :key
6
+ attr_accessor :timeout
7
+
8
+ def initialize(key, options = {})
9
+ @key = key
10
+
11
+ # 3 minute default timeout
12
+ @timeout = options[:timeout] || 60 * 3
13
+ end
14
+
15
+ # Attempts to acquire the lock. Returns true if successfully acquired.
16
+ def acquire!
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def value
21
+ @value ||= [hostname, process_id].join(':')
22
+ end
23
+
24
+ # Returns true if you currently hold the lock.
25
+ def locked?
26
+ raise NotImplementedError
27
+ end
28
+
29
+ # Releases the lock.
30
+ def release!
31
+ Resque.redis.del(key) == 1
32
+ end
33
+
34
+ private
35
+
36
+ # Extends the lock by `timeout` seconds.
37
+ def extend_lock!
38
+ Resque.redis.expire(key, timeout)
39
+ end
40
+
41
+ def hostname
42
+ local_hostname = Socket.gethostname
43
+ Socket.gethostbyname(local_hostname).first rescue local_hostname
44
+ end
45
+
46
+ def process_id
47
+ Process.pid
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,28 @@
1
+ require 'resque/scheduler/lock/base'
2
+
3
+ module Resque
4
+ class Scheduler
5
+ module Lock
6
+ class Basic < Base
7
+ def acquire!
8
+ if Resque.redis.setnx(key, value)
9
+ extend_lock!
10
+ true
11
+ end
12
+ end
13
+
14
+ def locked?
15
+ if Resque.redis.get(key) == value
16
+ extend_lock!
17
+
18
+ if Resque.redis.get(key) == value
19
+ return true
20
+ end
21
+ end
22
+
23
+ false
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,69 @@
1
+ require 'resque/scheduler/lock/base'
2
+
3
+ module Resque
4
+ class Scheduler
5
+ module Lock
6
+ class Resilient < Base
7
+ def acquire!
8
+ Resque.redis.evalsha(
9
+ acquire_sha,
10
+ :keys => [key],
11
+ :argv => [value]
12
+ ).to_i == 1
13
+ end
14
+
15
+ def locked?
16
+ Resque.redis.evalsha(
17
+ locked_sha,
18
+ :keys => [key],
19
+ :argv => [value]
20
+ ).to_i == 1
21
+ end
22
+
23
+ private
24
+
25
+ def locked_sha(refresh = false)
26
+ @locked_sha = nil if refresh
27
+
28
+ @locked_sha ||= begin
29
+ Resque.redis.script(
30
+ :load,
31
+ <<-EOF
32
+ if redis.call('GET', KEYS[1]) == ARGV[1]
33
+ then
34
+ redis.call('EXPIRE', KEYS[1], #{timeout})
35
+
36
+ if redis.call('GET', KEYS[1]) == ARGV[1]
37
+ then
38
+ return 1
39
+ end
40
+ end
41
+
42
+ return 0
43
+ EOF
44
+ )
45
+ end
46
+ end
47
+
48
+ def acquire_sha(refresh = false)
49
+ @acquire_sha = nil if refresh
50
+
51
+ @acquire_sha ||= begin
52
+ Resque.redis.script(
53
+ :load,
54
+ <<-EOF
55
+ if redis.call('SETNX', KEYS[1], ARGV[1]) == 1
56
+ then
57
+ redis.call('EXPIRE', KEYS[1], #{timeout})
58
+ return 1
59
+ else
60
+ return 0
61
+ end
62
+ EOF
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,90 @@
1
+
2
+ # ### Locking the scheduler process
3
+ #
4
+ # There are two places in resque-scheduler that need to be synchonized
5
+ # in order to be able to run redundant scheduler processes while ensuring jobs don't
6
+ # get queued multiple times when the master process changes.
7
+ #
8
+ # 1) Processing the delayed queues (jobs that are created from enqueue_at/enqueue_in, etc)
9
+ # 2) Processing the scheduled (cron-like) jobs from rufus-scheduler
10
+ #
11
+ # Protecting the delayed queues (#1) is relatively easy. A simple SETNX in
12
+ # redis would suffice. However, protecting the scheduled jobs is trickier
13
+ # because the clocks on machines could be slightly off or actual firing times
14
+ # could vary slightly due to load. If scheduler A's clock is slightly ahead
15
+ # of scheduler B's clock (since they are on different machines), when
16
+ # scheduler A dies, we need to ensure that scheduler B doesn't queue jobs
17
+ # that A already queued before it's death. (This all assumes that it is
18
+ # better to miss a few scheduled jobs than it is to run them multiple times
19
+ # for the same iteration.)
20
+ #
21
+ # To avoid queuing multiple jobs in the case of master fail-over, the master
22
+ # should remain the master as long as it can rather than a simple SETNX which
23
+ # would result in the master roll being passed around frequently.
24
+ #
25
+ # Locking Scheme:
26
+ # Each resque-scheduler process attempts to get the master lock via SETNX.
27
+ # Once obtained, it sets the expiration for 3 minutes (configurable). The
28
+ # master process continually updates the timeout on the lock key to be 3
29
+ # minutes in the future in it's loop(s) (see `run`) and when jobs come out of
30
+ # rufus-scheduler (see `load_schedule_job`). That ensures that a minimum of
31
+ # 3 minutes must pass since the last queuing operation before a new master is
32
+ # chosen. If, for whatever reason, the master fails to update the expiration
33
+ # for 3 minutes, the key expires and the lock is up for grabs. If
34
+ # miraculously the original master comes back to life, it will realize it is
35
+ # no longer the master and stop processing jobs.
36
+ #
37
+ # The clocks on the scheduler machines can then be up to 3 minutes off from
38
+ # each other without the risk of queueing the same scheduled job twice during
39
+ # a master change. The catch is, in the event of a master change, no
40
+ # scheduled jobs will be queued during those 3 minutes. So, there is a trade
41
+ # off: the higher the timeout, the less likely scheduled jobs will be fired
42
+ # twice but greater chances of missing scheduled jobs. The lower the timeout,
43
+ # less likely jobs will be missed, greater the chances of jobs firing twice. If
44
+ # you don't care about jobs firing twice or are certain your machines' clocks
45
+ # are well in sync, a lower timeout is preferable. One thing to keep in mind:
46
+ # this only effects *scheduled* jobs - delayed jobs will never be lost or
47
+ # skipped since eventually a master will come online and it will process
48
+ # everything that is ready (no matter how old it is). Scheduled jobs work
49
+ # like cron - if you stop cron, no jobs fire while it's stopped and it doesn't
50
+ # fire jobs that were missed when it starts up again.
51
+
52
+ require 'resque/scheduler/lock'
53
+
54
+ module Resque
55
+ module SchedulerLocking
56
+ def master_lock
57
+ @master_lock ||= build_master_lock
58
+ end
59
+
60
+ def supports_lua?
61
+ redis_master_version >= 2.5
62
+ end
63
+
64
+ def is_master?
65
+ master_lock.acquire! || master_lock.locked?
66
+ end
67
+
68
+ def release_master_lock!
69
+ master_lock.release!
70
+ end
71
+
72
+ private
73
+
74
+ def build_master_lock
75
+ if supports_lua?
76
+ Resque::Scheduler::Lock::Resilient.new(master_lock_key)
77
+ else
78
+ Resque::Scheduler::Lock::Basic.new(master_lock_key)
79
+ end
80
+ end
81
+
82
+ def master_lock_key
83
+ :resque_scheduler_master_lock
84
+ end
85
+
86
+ def redis_master_version
87
+ Resque.redis.info['redis_version'].to_f
88
+ end
89
+ end
90
+ end
@@ -123,7 +123,13 @@ module ResqueScheduler
123
123
 
124
124
  if Resque.inline?
125
125
  # Just create the job and let resque perform it right away with inline.
126
- Resque::Job.create(queue, klass, *args)
126
+ # If the class is a custom job class, call self#scheduled on it. This allows you to do things like
127
+ # Resque.enqueue_at(timestamp, CustomJobClass, :opt1 => val1). Otherwise, pass off to Resque.
128
+ if klass.respond_to?(:scheduled)
129
+ klass.scheduled(queue, klass.to_s(), *args)
130
+ else
131
+ Resque::Job.create(queue, klass, *args)
132
+ end
127
133
  else
128
134
  delayed_push(timestamp, job_to_hash_with_queue(queue, klass, args))
129
135
  end
@@ -216,12 +222,13 @@ module ResqueScheduler
216
222
  # Given an encoded item, remove it from the delayed_queue
217
223
  #
218
224
  # This method is potentially very expensive since it needs to scan
219
- # through the delayed queue for every timestamp.
225
+ # through the delayed queue for every timestamp, but at least it
226
+ # doesn't kill Redis by calling redis.keys.
220
227
  def remove_delayed(klass, *args)
221
228
  destroyed = 0
222
229
  search = encode(job_to_hash(klass, args))
223
- Array(redis.keys("delayed:*")).each do |key|
224
- destroyed += redis.lrem key, 0, search
230
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |timestamp|
231
+ destroyed += redis.lrem "delayed:#{timestamp}", 0, search
225
232
  end
226
233
  destroyed
227
234
  end
@@ -258,6 +265,9 @@ module ResqueScheduler
258
265
 
259
266
  def clean_up_timestamp(key, timestamp)
260
267
  # If the list is empty, remove it.
268
+
269
+ # Use a watch here to ensure nobody adds jobs to this delayed
270
+ # queue while we're removing it.
261
271
  redis.watch key
262
272
  if 0 == redis.llen(key).to_i
263
273
  redis.multi do
@@ -268,6 +278,7 @@ module ResqueScheduler
268
278
  redis.unwatch
269
279
  end
270
280
  end
281
+
271
282
  def validate_job!(klass)
272
283
  if klass.to_s.empty?
273
284
  raise Resque::NoClassError.new("Jobs must be given a class.")
@@ -1,3 +1,3 @@
1
1
  module ResqueScheduler
2
- VERSION = '2.0.0'
2
+ VERSION = '2.0.1'
3
3
  end
@@ -184,6 +184,25 @@ context "DelayedQueue" do
184
184
  Resque::Scheduler.handle_delayed_items(t)
185
185
  end
186
186
 
187
+ test "calls klass#scheduled when enqueuing jobs if it exists" do
188
+ t = Time.now - 60
189
+ Resque.enqueue_at(t, FakeCustomJobClassEnqueueAt, :foo => "bar")
190
+ FakeCustomJobClassEnqueueAt.expects(:scheduled).once.with('test', FakeCustomJobClassEnqueueAt.to_s, {"foo" => "bar"})
191
+ Resque::Scheduler.handle_delayed_items
192
+ end
193
+
194
+ test "when Resque.inline = true, calls klass#scheduled when enqueuing jobs if it exists" do
195
+ old_val = Resque.inline
196
+ begin
197
+ Resque.inline = true
198
+ t = Time.now - 60
199
+ FakeCustomJobClassEnqueueAt.expects(:scheduled).once.with(:test, FakeCustomJobClassEnqueueAt.to_s, {:foo => "bar"})
200
+ Resque.enqueue_at(t, FakeCustomJobClassEnqueueAt, :foo => "bar")
201
+ ensure
202
+ Resque.inline = old_val
203
+ end
204
+ end
205
+
187
206
  test "enqueue_delayed_items_for_timestamp creates jobs and empties the delayed queue" do
188
207
  t = Time.now + 60
189
208
 
data/test/redis-test.conf CHANGED
@@ -106,10 +106,3 @@ databases 16
106
106
  # errors for write operations, and this may even lead to DB inconsistency.
107
107
 
108
108
  # maxmemory <bytes>
109
-
110
- ############################### ADVANCED CONFIG ###############################
111
-
112
- # Glue small output buffers together in order to send small replies in a
113
- # single TCP packet. Uses a bit more CPU but most of the times it is a win
114
- # in terms of number of queries per second. Use 'yes' if unsure.
115
- glueoutputbuf yes
@@ -0,0 +1,180 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ module LockTestHelper
4
+ def lock_is_not_held(lock)
5
+ Resque.redis.set(lock.key, 'anothermachine:1234')
6
+ end
7
+ end
8
+
9
+ context 'Resque::SchedulerLocking' do
10
+ setup do
11
+ @subject = Class.new { extend Resque::SchedulerLocking }
12
+ end
13
+
14
+ teardown do
15
+ Resque.redis.del(@subject.master_lock.key)
16
+ end
17
+
18
+ test 'it should use the basic lock mechanism for <= Redis 2.4' do
19
+ Resque.redis.stubs(:info).returns('redis_version' => '2.4.16')
20
+
21
+ assert_equal @subject.master_lock.class, Resque::Scheduler::Lock::Basic
22
+ end
23
+
24
+ test 'it should use the resilient lock mechanism for > Redis 2.4' do
25
+ Resque.redis.stubs(:info).returns('redis_version' => '2.5.12')
26
+
27
+ assert_equal @subject.master_lock.class, Resque::Scheduler::Lock::Resilient
28
+ end
29
+
30
+ test 'it should be the master if the lock is held' do
31
+ @subject.master_lock.acquire!
32
+ assert @subject.is_master?, 'should be master'
33
+ end
34
+
35
+ test 'it should not be the master if the lock is held by someone else' do
36
+ Resque.redis.set(@subject.master_lock.key, 'somethingelse:1234')
37
+ assert !@subject.is_master?, 'should not be master'
38
+ end
39
+
40
+ test "release_master_lock should delegate to master_lock" do
41
+ @subject.master_lock.expects(:release!)
42
+ @subject.release_master_lock!
43
+ end
44
+ end
45
+
46
+ context 'Resque::Scheduler::Lock::Base' do
47
+ setup do
48
+ @lock = Resque::Scheduler::Lock::Base.new('test_lock_key')
49
+ end
50
+
51
+ test '#acquire! should be not implemented' do
52
+ assert_raise(NotImplementedError) do
53
+ @lock.acquire!
54
+ end
55
+ end
56
+
57
+ test '#locked? should be not implemented' do
58
+ assert_raise(NotImplementedError) do
59
+ @lock.locked?
60
+ end
61
+ end
62
+ end
63
+
64
+ context 'Resque::Scheduler::Lock::Basic' do
65
+ include LockTestHelper
66
+
67
+ setup do
68
+ @lock = Resque::Scheduler::Lock::Basic.new('test_lock_key')
69
+ end
70
+
71
+ teardown do
72
+ @lock.release!
73
+ end
74
+
75
+ test 'you should not have the lock if someone else holds it' do
76
+ lock_is_not_held(@lock)
77
+
78
+ assert !@lock.locked?
79
+ end
80
+
81
+ test 'you should not be able to acquire the lock if someone else holds it' do
82
+ lock_is_not_held(@lock)
83
+
84
+ assert !@lock.acquire!
85
+ end
86
+
87
+ test "the lock should receive a TTL on acquiring" do
88
+ @lock.acquire!
89
+
90
+ assert Resque.redis.ttl(@lock.key) > 0, "lock should expire"
91
+ end
92
+
93
+ test 'releasing should release the master lock' do
94
+ assert @lock.acquire!, 'should have acquired the master lock'
95
+ assert @lock.locked?, 'should be locked'
96
+
97
+ @lock.release!
98
+
99
+ assert !@lock.locked?, 'should not be locked'
100
+ end
101
+
102
+ test 'checking the lock should increase the TTL if we hold it' do
103
+ @lock.acquire!
104
+ Resque.redis.setex(@lock.key, 10, @lock.value)
105
+
106
+ @lock.locked?
107
+
108
+ assert Resque.redis.ttl(@lock.key) > 10, "TTL should have been updated"
109
+ end
110
+
111
+ test 'checking the lock should not increase the TTL if we do not hold it' do
112
+ Resque.redis.setex(@lock.key, 10, @lock.value)
113
+ lock_is_not_held(@lock)
114
+
115
+ @lock.locked?
116
+
117
+ assert Resque.redis.ttl(@lock.key) <= 10, "TTL should not have been updated"
118
+ end
119
+ end
120
+
121
+ context 'Resque::Scheduler::Lock::Resilient' do
122
+ include LockTestHelper
123
+
124
+ if !Resque::Scheduler.supports_lua?
125
+ puts "*** Skipping Resque::Scheduler::Lock::Resilient tests, as they require Redis >= 2.5."
126
+ else
127
+ setup do
128
+ @lock = Resque::Scheduler::Lock::Resilient.new('test_resilient_lock')
129
+ end
130
+
131
+ teardown do
132
+ @lock.release!
133
+ end
134
+
135
+ test 'you should not have the lock if someone else holds it' do
136
+ lock_is_not_held(@lock)
137
+
138
+ assert !@lock.locked?, 'you should not have the lock'
139
+ end
140
+
141
+ test 'you should not be able to acquire the lock if someone else holds it' do
142
+ lock_is_not_held(@lock)
143
+
144
+ assert !@lock.acquire!
145
+ end
146
+
147
+ test "the lock should receive a TTL on acquiring" do
148
+ @lock.acquire!
149
+
150
+ assert Resque.redis.ttl(@lock.key) > 0, "lock should expire"
151
+ end
152
+
153
+ test 'releasing should release the master lock' do
154
+ assert @lock.acquire!, 'should have acquired the master lock'
155
+ assert @lock.locked?, 'should be locked'
156
+
157
+ @lock.release!
158
+
159
+ assert !@lock.locked?, 'should not be locked'
160
+ end
161
+
162
+ test 'checking the lock should increase the TTL if we hold it' do
163
+ @lock.acquire!
164
+ Resque.redis.setex(@lock.key, 10, @lock.value)
165
+
166
+ @lock.locked?
167
+
168
+ assert Resque.redis.ttl(@lock.key) > 10, "TTL should have been updated"
169
+ end
170
+
171
+ test 'checking the lock should not increase the TTL if we do not hold it' do
172
+ Resque.redis.setex(@lock.key, 10, @lock.value)
173
+ lock_is_not_held(@lock)
174
+
175
+ @lock.locked?
176
+
177
+ assert Resque.redis.ttl(@lock.key) <= 10, "TTL should not have been updated"
178
+ end
179
+ end
180
+ end
@@ -4,8 +4,7 @@ context "Resque::Scheduler" do
4
4
 
5
5
  setup do
6
6
  Resque::Scheduler.dynamic = false
7
- Resque.redis.del(:schedules)
8
- Resque.redis.del(:schedules_changed)
7
+ Resque.redis.flushall
9
8
  Resque::Scheduler.mute = true
10
9
  Resque::Scheduler.clear_schedule!
11
10
  Resque::Scheduler.send(:class_variable_set, :@@scheduled_jobs, {})
@@ -72,15 +71,15 @@ context "Resque::Scheduler" do
72
71
  assert Resque::Scheduler.scheduled_jobs.include?("some_ivar_job2")
73
72
  end
74
73
 
75
- test "load_schedule_job loads a schedule" do
74
+ test "load_schedule_job loads a schedule" do
76
75
  Resque::Scheduler.load_schedule_job("some_ivar_job", {'cron' => "* * * * *", 'class' => 'SomeIvarJob', 'args' => "/tmp"})
77
76
 
78
77
  assert_equal(1, Resque::Scheduler.rufus_scheduler.all_jobs.size)
79
78
  assert_equal(1, Resque::Scheduler.scheduled_jobs.size)
80
79
  assert Resque::Scheduler.scheduled_jobs.keys.include?("some_ivar_job")
81
80
  end
82
-
83
- test "load_schedule_job with every with options" do
81
+
82
+ test "load_schedule_job with every with options" do
84
83
  Resque::Scheduler.load_schedule_job("some_ivar_job", {'every' => ['30s', {'first_in' => '60s'}], 'class' => 'SomeIvarJob', 'args' => "/tmp"})
85
84
 
86
85
  assert_equal(1, Resque::Scheduler.rufus_scheduler.all_jobs.size)
@@ -88,8 +87,8 @@ context "Resque::Scheduler" do
88
87
  assert Resque::Scheduler.scheduled_jobs.keys.include?("some_ivar_job")
89
88
  assert Resque::Scheduler.scheduled_jobs["some_ivar_job"].params.keys.include?(:first_in)
90
89
  end
91
-
92
- test "load_schedule_job with cron with options" do
90
+
91
+ test "load_schedule_job with cron with options" do
93
92
  Resque::Scheduler.load_schedule_job("some_ivar_job", {'cron' => ['* * * * *', {'allow_overlapping' => 'true'}], 'class' => 'SomeIvarJob', 'args' => "/tmp"})
94
93
 
95
94
  assert_equal(1, Resque::Scheduler.rufus_scheduler.all_jobs.size)
@@ -243,5 +242,4 @@ context "Resque::Scheduler" do
243
242
  Resque::Plugin.lint(ResqueScheduler)
244
243
  end
245
244
  end
246
-
247
245
  end
@@ -0,0 +1,129 @@
1
+ require 'socket'
2
+ require 'timeout'
3
+ require 'fileutils'
4
+
5
+ class RedisInstance
6
+ class << self
7
+ @running = false
8
+ @port = nil
9
+ @pid = nil
10
+
11
+ def run_if_needed!
12
+ run! unless running?
13
+ end
14
+
15
+ def run!
16
+ ensure_pid_directory
17
+ reassign_redis_clients
18
+ start_redis_server
19
+
20
+ if $?.success?
21
+ wait_for_pid
22
+ puts "Booted isolated Redis on port #{port} with PID #{pid}."
23
+
24
+ wait_for_redis_boot
25
+
26
+ # Ensure we tear down Redis on Ctrl+C / test failure.
27
+ at_exit do
28
+ RedisInstance.stop!
29
+ end
30
+ else
31
+ raise "Failed to start Redis on port #{port}."
32
+ end
33
+
34
+ @running = true
35
+ end
36
+
37
+ def stop!
38
+ $stdout.puts "Sending TERM to Redis (#{pid})..."
39
+ Process.kill('TERM', pid)
40
+
41
+ @port = nil
42
+ @running = false
43
+ @pid = nil
44
+ end
45
+
46
+ def running?
47
+ @running
48
+ end
49
+
50
+ private
51
+
52
+ def wait_for_redis_boot
53
+ Timeout::timeout(10) do
54
+ begin
55
+ while Resque.redis.ping != 'PONG'
56
+ end
57
+ rescue
58
+ # silence all errors
59
+ end
60
+ end
61
+ end
62
+
63
+ def ensure_pid_directory
64
+ FileUtils.mkdir_p(File.dirname(pid_file))
65
+ end
66
+
67
+ def reassign_redis_clients
68
+ Resque.redis = Redis.new(:hostname => '127.0.0.1', :port => port, :thread_safe => true)
69
+ end
70
+
71
+ def start_redis_server
72
+ IO.popen("redis-server -", "w+") do |server|
73
+ server.write(config)
74
+ server.close_write
75
+ end
76
+ end
77
+
78
+ def pid
79
+ @pid ||= File.read(pid_file).to_i
80
+ end
81
+
82
+ def wait_for_pid
83
+ Timeout::timeout(10) do
84
+ while !File.exist?(pid_file)
85
+ end
86
+ end
87
+ end
88
+
89
+ def port
90
+ @port ||= random_port
91
+ end
92
+
93
+ def pid_file
94
+ "/tmp/redis-scheduler-test.pid"
95
+ end
96
+
97
+ def config
98
+ <<-EOF
99
+ daemonize yes
100
+ pidfile #{pid_file}
101
+ port #{port}
102
+ EOF
103
+ end
104
+
105
+ # Returns a random port in the upper (10000-65535) range.
106
+ def random_port
107
+ ports = (10000..65535).to_a
108
+
109
+ loop do
110
+ port = ports[rand(ports.size)]
111
+ return port if port_available?('127.0.0.1', port)
112
+ end
113
+ end
114
+
115
+ def port_available?(ip, port, seconds=1)
116
+ Timeout::timeout(seconds) do
117
+ begin
118
+ TCPSocket.new(ip, port).close
119
+ false
120
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
121
+ true
122
+ end
123
+ end
124
+ rescue Timeout::Error
125
+ true
126
+ end
127
+ end
128
+ end
129
+
data/test/test_helper.rb CHANGED
@@ -23,10 +23,10 @@ if !system("which redis-server")
23
23
  end
24
24
 
25
25
 
26
- #
27
- # start our own redis when the tests start,
28
- # kill it when they end
29
- #
26
+ # Start our own Redis when the tests start. RedisInstance will take care of
27
+ # starting and stopping.
28
+ require File.dirname(__FILE__) + '/support/redis_instance'
29
+ RedisInstance.run!
30
30
 
31
31
  at_exit do
32
32
  next if $!
@@ -37,17 +37,9 @@ at_exit do
37
37
  exit_code = Test::Unit::AutoRunner.run
38
38
  end
39
39
 
40
- pid = `ps -e -o pid,command | grep [r]edis-test`.split(" ")[0]
41
- puts "Killing test redis server..."
42
- `rm -f #{dir}/dump.rdb`
43
- Process.kill("KILL", pid.to_i)
44
40
  exit exit_code
45
41
  end
46
42
 
47
- puts "Starting redis for testing at localhost:9736..."
48
- `redis-server #{dir}/redis-test.conf`
49
- Resque.redis = 'localhost:9736'
50
-
51
43
  ##
52
44
  # test/spec/mini 3
53
45
  # http://gist.github.com/25455
@@ -72,6 +64,11 @@ class FakeCustomJobClass
72
64
  def self.scheduled(queue, klass, *args); end
73
65
  end
74
66
 
67
+ class FakeCustomJobClassEnqueueAt
68
+ @queue = :test
69
+ def self.scheduled(queue, klass, *args); end
70
+ end
71
+
75
72
  class SomeJob
76
73
  def self.perform(repo_id, path)
77
74
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: resque-scheduler
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-05-04 00:00:00.000000000 Z
12
+ date: 2013-03-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: bundler
@@ -91,6 +91,11 @@ files:
91
91
  - README.markdown
92
92
  - Rakefile
93
93
  - lib/resque/scheduler.rb
94
+ - lib/resque/scheduler/lock.rb
95
+ - lib/resque/scheduler/lock/base.rb
96
+ - lib/resque/scheduler/lock/basic.rb
97
+ - lib/resque/scheduler/lock/resilient.rb
98
+ - lib/resque/scheduler_locking.rb
94
99
  - lib/resque_scheduler.rb
95
100
  - lib/resque_scheduler/plugin.rb
96
101
  - lib/resque_scheduler/server.rb
@@ -106,7 +111,9 @@ files:
106
111
  - test/resque-web_test.rb
107
112
  - test/scheduler_args_test.rb
108
113
  - test/scheduler_hooks_test.rb
114
+ - test/scheduler_locking_test.rb
109
115
  - test/scheduler_test.rb
116
+ - test/support/redis_instance.rb
110
117
  - test/test_helper.rb
111
118
  homepage: http://github.com/bvandenbos/resque-scheduler
112
119
  licenses: []
@@ -128,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
135
  version: 1.3.6
129
136
  requirements: []
130
137
  rubyforge_project:
131
- rubygems_version: 1.8.21
138
+ rubygems_version: 1.8.23
132
139
  signing_key:
133
140
  specification_version: 3
134
141
  summary: Light weight job scheduling on top of Resque